From 6c660d57212f46c3a3891b0cc4e4b6c0467e9c6d Mon Sep 17 00:00:00 2001 From: Shannon Deminick Date: Tue, 20 Apr 2021 17:13:40 +1000 Subject: [PATCH] Security stamp implementation for members (#10140) * Getting new netcore PublicAccessChecker in place * Adds full test coverage for PublicAccessChecker * remove PublicAccessComposer * adjust namespaces, ensure RoleManager works, separate public access controller, reduce content controller * Implements the required methods on IMemberManager, removes old migrated code * Updates routing to be able to re-route, Fixes middleware ordering ensuring endpoints are last, refactors pipeline options, adds public access middleware, ensures public access follows all hops * adds note * adds note * Cleans up ext methods, ensures that members identity is added on both front-end and back ends. updates how UmbracoApplicationBuilder works in that it explicitly starts endpoints at the time of calling. * Changes name to IUmbracoEndpointBuilder * adds note * Fixing tests, fixing error describers so there's 2x one for back office, one for members, fixes TryConvertTo, fixes login redirect * fixing build * Updates user manager to correctly validate password hashing and injects the IBackOfficeUserPasswordChecker * Merges PR * Fixes up build and notes * Implements security stamp and email confirmed for members, cleans up a bunch of repo/service level member groups stuff, shares user store code between members and users and fixes the user identity object so we arent' tracking both groups and roles. * Security stamp for members is now working * Fixes keepalive, fixes PublicAccessMiddleware to not throw, updates startup code to be more clear and removes magic that registers middleware. * adds note * removes unused filter, fixes build * fixes WebPath and tests * Looks up entities in one query * remove usings * Fix test, remove stylesheet * Set status code before we write to response to avoid error * Ensures that users and members are validated when logging in. Shares more code between users and members. * merge changes * oops * Fixes RepositoryCacheKeys to ensure the keys are normalized * oops didn't mean to commit this * Fix casing issues with caching, stop boxing value types for all cache operations, stop re-creating string keys in DefaultRepositoryCachePolicy * oops didn't mean to comit this * bah, far out this keeps getting recommitted. sorry Co-authored-by: Bjarke Berg --- src/Umbraco.Core/Models/Membership/User.cs | 5 +- .../Repositories/IMemberGroupRepository.cs | 8 +- .../Services/IMembershipRoleService.cs | 5 + .../Migrations/Upgrade/UmbracoPlan.cs | 3 +- .../V_9_0_0/ExternalLoginTokenTable.cs | 3 +- .../Upgrade/V_9_0_0/MemberTableColumns.cs | 24 ++ .../Persistence/Dtos/MemberDto.cs | 14 +- .../Factories/ContentBaseFactory.cs | 10 +- .../Repositories/IMemberRepository.cs | 4 +- .../Implement/MemberGroupRepository.cs | 102 +++-- .../Implement/MemberRepository.cs | 64 ++- .../Repositories/Implement/UserRepository.cs | 25 +- .../Security/BackOfficeIdentityUser.cs | 48 +-- .../Security/BackOfficeUserStore.cs | 269 +----------- .../Security/ClaimsIdentityExtensions.cs | 2 +- .../Security/IdentityMapDefinition.cs | 1 + .../Security/MemberIdentityUser.cs | 2 +- .../Security/MemberUserStore.cs | 390 +++++------------- .../Security/UmbracoUserStore.cs | 247 +++++++++++ .../Services/Implement/MemberService.cs | 29 +- .../Services/MemberGroupServiceTests.cs | 36 -- .../Services/MemberServiceTests.cs | 19 + .../Security/MemberUserStoreTests.cs | 5 + ...BackOfficeSecurityStampValidatorOptions.cs | 23 +- .../UmbracoBuilder.MembersIdentity.cs | 2 + .../Security/ConfigureSecurityStampOptions.cs | 39 ++ 26 files changed, 670 insertions(+), 709 deletions(-) create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/MemberTableColumns.cs create mode 100644 src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs delete mode 100644 src/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberGroupServiceTests.cs create mode 100644 src/Umbraco.Web.Common/Security/ConfigureSecurityStampOptions.cs diff --git a/src/Umbraco.Core/Models/Membership/User.cs b/src/Umbraco.Core/Models/Membership/User.cs index 7806a7dd52..47de82487f 100644 --- a/src/Umbraco.Core/Models/Membership/User.cs +++ b/src/Umbraco.Core/Models/Membership/User.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; @@ -135,18 +135,21 @@ namespace Umbraco.Cms.Core.Models.Membership get => _emailConfirmedDate; set => SetPropertyValueAndDetectChanges(value, ref _emailConfirmedDate, nameof(EmailConfirmedDate)); } + [DataMember] public DateTime? InvitedDate { get => _invitedDate; set => SetPropertyValueAndDetectChanges(value, ref _invitedDate, nameof(InvitedDate)); } + [DataMember] public string Username { get => _username; set => SetPropertyValueAndDetectChanges(value, ref _username, nameof(Username)); } + [DataMember] public string Email { diff --git a/src/Umbraco.Core/Persistence/Repositories/IMemberGroupRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IMemberGroupRepository.cs index 74fdc4d00c..61c0b0c0a1 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IMemberGroupRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IMemberGroupRepository.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Umbraco.Cms.Core.Models; @@ -40,14 +40,12 @@ namespace Umbraco.Cms.Core.Persistence.Repositories /// IEnumerable GetMemberGroupsForMember(string username); - void AssignRoles(string[] usernames, string[] roleNames); - - void DissociateRoles(string[] usernames, string[] roleNames); + void ReplaceRoles(int[] memberIds, string[] roleNames); void AssignRoles(int[] memberIds, string[] roleNames); void DissociateRoles(int[] memberIds, string[] roleNames); - int[] GetMemberIds(string[] names); + } } diff --git a/src/Umbraco.Core/Services/IMembershipRoleService.cs b/src/Umbraco.Core/Services/IMembershipRoleService.cs index b9e5912c1b..be7c75c766 100644 --- a/src/Umbraco.Core/Services/IMembershipRoleService.cs +++ b/src/Umbraco.Core/Services/IMembershipRoleService.cs @@ -43,5 +43,10 @@ namespace Umbraco.Cms.Core.Services void DissociateRole(int memberId, string roleName); void DissociateRoles(int[] memberIds, string[] roleNames); + + void ReplaceRoles(string[] usernames, string[] roleNames); + + void ReplaceRoles(int[] memberIds, string[] roleNames); + } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 3879549e43..526f5a7279 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -1,4 +1,4 @@ -using System; +using System; using Umbraco.Cms.Core.Configuration; using Umbraco.Cms.Core.Semver; using Umbraco.Cms.Infrastructure.Migrations.Upgrade.Common; @@ -204,6 +204,7 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade To("{22D801BA-A1FF-4539-BFCC-2139B55594F8}"); To("{50A43237-A6F4-49E2-A7A6-5DAD65C84669}"); To("{3D8DADEF-0FDA-4377-A5F0-B52C2110E8F2}"); + To("{1303BDCF-2295-4645-9526-2F32E8B35ABD}"); //FINAL } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/ExternalLoginTokenTable.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/ExternalLoginTokenTable.cs index e098bbd0b9..418f0c3275 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/ExternalLoginTokenTable.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/ExternalLoginTokenTable.cs @@ -1,9 +1,10 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Umbraco.Cms.Infrastructure.Persistence.Dtos; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_0_0 { + public class ExternalLoginTokenTable : MigrationBase { public ExternalLoginTokenTable(IMigrationContext context) diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/MemberTableColumns.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/MemberTableColumns.cs new file mode 100644 index 0000000000..4ef3d070ec --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/MemberTableColumns.cs @@ -0,0 +1,24 @@ +using System.Linq; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_0_0 +{ + public class MemberTableColumns : MigrationBase + { + public MemberTableColumns(IMigrationContext context) + : base(context) + { + } + + /// + /// Adds new External Login token table + /// + public override void Migrate() + { + var columns = SqlSyntax.GetColumnsInSchema(Context.Database).ToList(); + + AddColumnIfNotExists(columns, "securityStampToken"); + AddColumnIfNotExists(columns, "emailConfirmedDate"); + } + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/MemberDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/MemberDto.cs index aebf8f7f1e..4568d30686 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/MemberDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/MemberDto.cs @@ -1,4 +1,5 @@ -using NPoco; +using System; +using NPoco; using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; namespace Umbraco.Cms.Infrastructure.Persistence.Dtos @@ -31,6 +32,17 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Dtos [Constraint(Default = "''")] public string Password { get; set; } + [Column("securityStampToken")] + [NullSetting(NullSetting = NullSettings.Null)] + [Length(255)] + public string SecurityStampToken { get; set; } + + [Column("emailConfirmedDate")] + [NullSetting(NullSetting = NullSettings.Null)] + public DateTime? EmailConfirmedDate { get; set; } + + // TODO: It would be SOOOOO much better to store all core member data here instead of hiding it in Umbraco properties + [ResultColumn] [Reference(ReferenceType.OneToOne, ReferenceMemberName = "NodeId")] public ContentDto ContentDto { get; set; } diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/ContentBaseFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/ContentBaseFactory.cs index 6560ea9611..76b9a30af0 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/ContentBaseFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/ContentBaseFactory.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Umbraco.Cms.Core.Models; @@ -125,9 +125,12 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Factories content.DisableChangeTracking(); content.Id = dto.NodeId; + content.SecurityStamp = dto.SecurityStampToken; + content.EmailConfirmedDate = dto.EmailConfirmedDate; + content.Key = nodeDto.UniqueId; content.VersionId = contentVersionDto.Id; - + // TODO: missing names? content.Path = nodeDto.Path; @@ -212,7 +215,8 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Factories LoginName = entity.Username, NodeId = entity.Id, Password = entity.RawPasswordValue, - + SecurityStampToken = entity.SecurityStamp, + EmailConfirmedDate = entity.EmailConfirmedDate, ContentDto = contentDto, ContentVersionDto = BuildContentVersionDto(entity, contentDto) }; diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/IMemberRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/IMemberRepository.cs index 24899a5759..9b24d60a9f 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/IMemberRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/IMemberRepository.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Persistence.Querying; @@ -7,6 +7,8 @@ namespace Umbraco.Cms.Core.Persistence.Repositories { public interface IMemberRepository : IContentRepository { + int[] GetMemberIds(string[] names); + IMember GetByUsername(string username); /// diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberGroupRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberGroupRepository.cs index c7324de914..b372c3b479 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberGroupRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberGroupRepository.cs @@ -133,7 +133,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement public IMemberGroup GetByName(string name) { return IsolatedCache.GetCacheItem( - typeof (IMemberGroup).FullName + "." + name, + typeof(IMemberGroup).FullName + "." + name, () => { var qry = Query().Where(group => group.Name.Equals(name)); @@ -151,7 +151,8 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement var qry = Query().Where(group => group.Name.Equals(roleName)); var result = Get(qry); - if (result.Any()) return null; + if (result.Any()) + return null; var grp = new MemberGroup { @@ -176,7 +177,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement .Select("umbracoNode.*") .From() .InnerJoin() - .On( dto => dto.NodeId, dto => dto.MemberGroup) + .On(dto => dto.NodeId, dto => dto.MemberGroup) .Where(x => x.NodeObjectType == NodeObjectTypeId) .Where(x => x.Member == memberId); @@ -202,50 +203,27 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement .Select(x => MemberGroupFactory.BuildEntity(x)); } - public int[] GetMemberIds(string[] usernames) - { - var memberObjectType = Cms.Core.Constants.ObjectTypes.Member; + - var memberSql = Sql() - .Select("umbracoNode.id") - .From() - .InnerJoin() - .On(dto => dto.NodeId, dto => dto.NodeId) - .Where(x => x.NodeObjectType == memberObjectType) - .Where("cmsMember.LoginName in (@usernames)", new { /*usernames =*/ usernames }); - return Database.Fetch(memberSql).ToArray(); - } + public void ReplaceRoles(int[] memberIds, string[] roleNames) => AssignRolesInternal(memberIds, roleNames, true); - public void AssignRoles(string[] usernames, string[] roleNames) - { - AssignRolesInternal(GetMemberIds(usernames), roleNames); - } + public void AssignRoles(int[] memberIds, string[] roleNames) => AssignRolesInternal(memberIds, roleNames); - public void DissociateRoles(string[] usernames, string[] roleNames) - { - DissociateRolesInternal(GetMemberIds(usernames), roleNames); - } - - public void AssignRoles(int[] memberIds, string[] roleNames) - { - AssignRolesInternal(memberIds, roleNames); - } - - public void AssignRolesInternal(int[] memberIds, string[] roleNames) + private void AssignRolesInternal(int[] memberIds, string[] roleNames, bool replace = false) { //ensure they're unique memberIds = memberIds.Distinct().ToArray(); //create the missing roles first - var existingSql = Sql() + Sql existingSql = Sql() .SelectAll() .From() .Where(dto => dto.NodeObjectType == NodeObjectTypeId) .Where("umbracoNode." + SqlSyntax.GetQuotedColumnName("text") + " in (@names)", new { names = roleNames }); - var existingRoles = Database.Fetch(existingSql).Select(x => x.Text); - var missingRoles = roleNames.Except(existingRoles, StringComparer.CurrentCultureIgnoreCase); - var missingGroups = missingRoles.Select(x => new MemberGroup {Name = x}).ToArray(); + IEnumerable existingRoles = Database.Fetch(existingSql).Select(x => x.Text); + IEnumerable missingRoles = roleNames.Except(existingRoles, StringComparer.CurrentCultureIgnoreCase); + MemberGroup[] missingGroups = missingRoles.Select(x => new MemberGroup { Name = x }).ToArray(); var evtMsgs = _eventMessagesFactory.Get(); if (AmbientScope.Notifications.PublishCancelable(new MemberGroupSavingNotification(missingGroups, evtMsgs))) @@ -253,25 +231,39 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement return; } - foreach (var m in missingGroups) + foreach (MemberGroup m in missingGroups) + { PersistNewItem(m); + } AmbientScope.Notifications.Publish(new MemberGroupSavedNotification(missingGroups, evtMsgs)); //now go get all the dto's for roles with these role names - var rolesForNames = Database.Fetch(existingSql).ToArray(); + var rolesForNames = Database.Fetch(existingSql) + .ToDictionary(x => x.Text, StringComparer.InvariantCultureIgnoreCase); - //get the groups that are currently assigned to any of these members + AssignedRolesDto[] currentlyAssigned; + if (replace) + { + // delete all assigned groups first + Database.Execute("DELETE FROM cmsMember2MemberGroup WHERE Member IN (@memberIds)", new { memberIds }); - var assignedSql = Sql() - .Select($"{SqlSyntax.GetQuotedColumnName("text")},{SqlSyntax.GetQuotedColumnName("Member")},{SqlSyntax.GetQuotedColumnName("MemberGroup")}") - .From() - .InnerJoin() - .On(dto => dto.NodeId, dto => dto.MemberGroup) - .Where(x => x.NodeObjectType == NodeObjectTypeId) - .Where("cmsMember2MemberGroup.Member in (@ids)", new { ids = memberIds }); + currentlyAssigned = Array.Empty(); + } + else + { + //get the groups that are currently assigned to any of these members - var currentlyAssigned = Database.Fetch(assignedSql).ToArray(); + Sql assignedSql = Sql() + .Select($"{SqlSyntax.GetQuotedColumnName("text")},{SqlSyntax.GetQuotedColumnName("Member")},{SqlSyntax.GetQuotedColumnName("MemberGroup")}") + .From() + .InnerJoin() + .On(dto => dto.NodeId, dto => dto.MemberGroup) + .Where(x => x.NodeObjectType == NodeObjectTypeId) + .Where("cmsMember2MemberGroup.Member in (@ids)", new { ids = memberIds }); + + currentlyAssigned = Database.Fetch(assignedSql).ToArray(); + } //assign the roles for each member id @@ -280,14 +272,18 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement //find any roles for the current member that are currently assigned that //exist in the roleNames list, then determine which ones are not currently assigned. var mId = memberId; - var found = currentlyAssigned.Where(x => x.MemberId == mId).ToArray(); - var assignedRoles = found.Where(x => roleNames.Contains(x.RoleName, StringComparer.CurrentCultureIgnoreCase)).Select(x => x.RoleName); - var nonAssignedRoles = roleNames.Except(assignedRoles, StringComparer.CurrentCultureIgnoreCase); - foreach (var toAssign in nonAssignedRoles) - { - var groupId = rolesForNames.First(x => x.Text.InvariantEquals(toAssign)).NodeId; - Database.Insert(new Member2MemberGroupDto { Member = mId, MemberGroup = groupId }); - } + AssignedRolesDto[] found = currentlyAssigned.Where(x => x.MemberId == mId).ToArray(); + IEnumerable assignedRoles = found.Where(x => roleNames.Contains(x.RoleName, StringComparer.CurrentCultureIgnoreCase)).Select(x => x.RoleName); + IEnumerable nonAssignedRoles = roleNames.Except(assignedRoles, StringComparer.CurrentCultureIgnoreCase); + + IEnumerable dtos = nonAssignedRoles + .Select(x => new Member2MemberGroupDto + { + Member = mId, + MemberGroup = rolesForNames[x].NodeId + }); + + Database.InsertBulk(dtos); } } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberRepository.cs index 4c107d2a01..be83b039a4 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberRepository.cs @@ -252,6 +252,12 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement { entity.AddingEntity(); + // ensure security stamp if missing + if (entity.SecurityStamp.IsNullOrWhiteSpace()) + { + entity.SecurityStamp = Guid.NewGuid().ToString(); + } + // ensure that strings don't contain characters that are invalid in xml // TODO: do we really want to keep doing this here? entity.SanitizeEntityPropertiesForXmlStorage(); @@ -342,6 +348,12 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement // update entity.UpdatingEntity(); + // ensure security stamp if missing + if (entity.SecurityStamp.IsNullOrWhiteSpace()) + { + entity.SecurityStamp = Guid.NewGuid().ToString(); + } + // ensure that strings don't contain characters that are invalid in xml // TODO: do we really want to keep doing this here? entity.SanitizeEntityPropertiesForXmlStorage(); @@ -373,18 +385,52 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement // but only the changed columns, 'cos we cannot update password if empty var changedCols = new List(); + if (entity.IsPropertyDirty("SecurityStamp")) + { + changedCols.Add("securityStampToken"); + } + if (entity.IsPropertyDirty("Email")) + { changedCols.Add("Email"); + } if (entity.IsPropertyDirty("Username")) + { changedCols.Add("LoginName"); + } // do NOT update the password if it has not changed or if it is null or empty if (entity.IsPropertyDirty("RawPasswordValue") && !string.IsNullOrWhiteSpace(entity.RawPasswordValue)) + { changedCols.Add("Password"); + // If the security stamp hasn't already updated we need to force it + if (entity.IsPropertyDirty("SecurityStamp") == false) + { + dto.SecurityStampToken = entity.SecurityStamp = Guid.NewGuid().ToString(); + changedCols.Add("securityStampToken"); + } + } + + // If userlogin or the email has changed then need to reset security stamp + if (changedCols.Contains("Email") || changedCols.Contains("LoginName")) + { + dto.EmailConfirmedDate = null; + changedCols.Add("emailConfirmedDate"); + + // If the security stamp hasn't already updated we need to force it + if (entity.IsPropertyDirty("SecurityStamp") == false) + { + dto.SecurityStampToken = entity.SecurityStamp = Guid.NewGuid().ToString(); + changedCols.Add("securityStampToken"); + } + } + if (changedCols.Count > 0) + { Database.Update(dto, changedCols); + } ReplacePropertyValues(entity, entity.VersionId, 0, out _, out _); @@ -655,8 +701,8 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement private IMember MapDtoToContent(MemberDto dto) { - var memberType = _memberTypeRepository.Get(dto.ContentDto.ContentTypeId); - var member = ContentBaseFactory.BuildEntity(dto, memberType); + IMemberType memberType = _memberTypeRepository.Get(dto.ContentDto.ContentTypeId); + Member member = ContentBaseFactory.BuildEntity(dto, memberType); // get properties - indexed by version id var versionId = dto.ContentVersionDto.Id; @@ -674,6 +720,20 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement return _memberByUsernameCachePolicy.Get(username, PerformGetByUsername, PerformGetAllByUsername); } + public int[] GetMemberIds(string[] usernames) + { + var memberObjectType = Cms.Core.Constants.ObjectTypes.Member; + + var memberSql = Sql() + .Select("umbracoNode.id") + .From() + .InnerJoin() + .On(dto => dto.NodeId, dto => dto.NodeId) + .Where(x => x.NodeObjectType == memberObjectType) + .Where("cmsMember.LoginName in (@usernames)", new { /*usernames =*/ usernames }); + return Database.Fetch(memberSql).ToArray(); + } + private IMember PerformGetByUsername(string username) { var query = Query().Where(x => x.Username.Equals(username)); diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs index 3fae13d117..734b3d07a0 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs @@ -69,7 +69,9 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement get { if (_passwordConfigInitialized) + { return _passwordConfigJson; + } var passwordConfig = new UserPasswordSettings { @@ -456,7 +458,9 @@ ORDER BY colName"; // ensure security stamp if missing if (entity.SecurityStamp.IsNullOrWhiteSpace()) + { entity.SecurityStamp = Guid.NewGuid().ToString(); + } var userDto = UserFactory.BuildDto(entity); @@ -504,7 +508,9 @@ ORDER BY colName"; // ensure security stamp if missing if (entity.SecurityStamp.IsNullOrWhiteSpace()) + { entity.SecurityStamp = Guid.NewGuid().ToString(); + } var userDto = UserFactory.BuildDto(entity); @@ -540,14 +546,17 @@ ORDER BY colName"; .Select(col => col.Key) .ToList(); + if (entity.IsPropertyDirty("SecurityStamp")) + { + changedCols.Add("securityStampToken"); + } + // DO NOT update the password if it has not changed or if it is null or empty if (entity.IsPropertyDirty("RawPasswordValue") && entity.RawPasswordValue.IsNullOrWhiteSpace() == false) { changedCols.Add("userPassword"); - // special case - when using ASP.Net identity the user manager will take care of updating the security stamp, however - // when not using ASP.Net identity (i.e. old membership providers), we'll need to take care of updating this manually - // so we can just detect if that property is dirty, if it's not we'll set it manually + // If the security stamp hasn't already updated we need to force it if (entity.IsPropertyDirty("SecurityStamp") == false) { userDto.SecurityStampToken = entity.SecurityStamp = Guid.NewGuid().ToString(); @@ -563,10 +572,14 @@ ORDER BY colName"; if (changedCols.Contains("userLogin") || changedCols.Contains("userEmail")) { userDto.EmailConfirmedDate = null; - userDto.SecurityStampToken = entity.SecurityStamp = Guid.NewGuid().ToString(); - changedCols.Add("emailConfirmedDate"); - changedCols.Add("securityStampToken"); + + // If the security stamp hasn't already updated we need to force it + if (entity.IsPropertyDirty("SecurityStamp") == false) + { + userDto.SecurityStampToken = entity.SecurityStamp = Guid.NewGuid().ToString(); + changedCols.Add("securityStampToken"); + } } //only update the changed cols diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs b/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs index 0ca109b741..9ddb67d611 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs @@ -19,11 +19,6 @@ namespace Umbraco.Cms.Core.Security private int[] _startMediaIds; private int[] _startContentIds; - // Custom comparer for enumerables - private static readonly DelegateEqualityComparer> s_groupsComparer = new DelegateEqualityComparer>( - (groups, enumerable) => groups.Select(x => x.Alias).UnsortedSequenceEqual(enumerable.Select(x => x.Alias)), - groups => groups.GetHashCode()); - private static readonly DelegateEqualityComparer s_startIdsComparer = new DelegateEqualityComparer( (groups, enumerable) => groups.UnsortedSequenceEqual(enumerable), groups => groups.GetHashCode()); @@ -64,8 +59,7 @@ namespace Umbraco.Cms.Core.Security _allowedSections = Array.Empty(); _culture = globalSettings.DefaultUILanguage; - // use the property setters - they do more than just setting a field - Groups = groups; + SetGroups(groups); } /// @@ -118,7 +112,7 @@ namespace Umbraco.Cms.Core.Security /// /// Gets a readonly list of the user's allowed sections which are based on it's user groups /// - public string[] AllowedSections => _allowedSections ?? (_allowedSections = _groups.SelectMany(x => x.AllowedSections).Distinct().ToArray()); + public string[] AllowedSections => _allowedSections ??= _groups.SelectMany(x => x.AllowedSections).Distinct().ToArray(); /// /// Gets or sets the culture @@ -132,31 +126,25 @@ namespace Umbraco.Cms.Core.Security /// /// Gets or sets the user groups /// - public IReadOnlyCollection Groups + public void SetGroups(IReadOnlyCollection value) { - get => _groups; - set + // so they recalculate + _allowedSections = null; + + _groups = value.Where(x => x.Alias != null).ToArray(); + + var roles = new List>(); + foreach (IdentityUserRole identityUserRole in _groups.Select(x => new IdentityUserRole { - // so they recalculate - _allowedSections = null; - - _groups = value.Where(x => x.Alias != null).ToArray(); - - var roles = new List>(); - foreach (IdentityUserRole identityUserRole in _groups.Select(x => new IdentityUserRole - { - RoleId = x.Alias, - UserId = Id?.ToString() - })) - { - roles.Add(identityUserRole); - } - - // now reset the collection - Roles = roles; - - BeingDirty.SetPropertyValueAndDetectChanges(value, ref _groups, nameof(Groups), s_groupsComparer); + RoleId = x.Alias, + UserId = Id?.ToString() + })) + { + roles.Add(identityUserRole); } + + // now reset the collection + Roles = roles; } private static string UserIdToString(int userId) => string.Intern(userId.ToString()); diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs index 04a7b12aec..e8d6303837 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs @@ -19,12 +19,11 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Core.Security { - // TODO: Make this into a base class that can be re-used /// /// The user store for back office users /// - public class BackOfficeUserStore : UserStoreBase, string, IdentityUserClaim, IdentityUserRole, IdentityUserLogin, IdentityUserToken, IdentityRoleClaim> + public class BackOfficeUserStore : UmbracoUserStore> { private readonly IScopeProvider _scopeProvider; private readonly IUserService _userService; @@ -59,19 +58,6 @@ namespace Umbraco.Cms.Core.Security _externalLoginService = externalLoginService; } - /// - /// Not supported in Umbraco - /// - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public override IQueryable Users => throw new NotImplementedException(); - - /// - public override Task GetNormalizedUserNameAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken) => GetUserNameAsync(user, cancellationToken); - - /// - public override Task SetNormalizedUserNameAsync(BackOfficeIdentityUser user, string normalizedName, CancellationToken cancellationToken) => SetUserNameAsync(user, normalizedName, cancellationToken); - /// public override Task CreateAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default) { @@ -215,9 +201,6 @@ namespace Umbraco.Cms.Core.Security return Task.FromResult(IdentityResult.Success); } - /// - public override Task FindByIdAsync(string userId, CancellationToken cancellationToken = default) => FindUserAsync(userId, cancellationToken); - /// protected override Task FindUserAsync(string userId, CancellationToken cancellationToken) { @@ -249,29 +232,6 @@ namespace Umbraco.Cms.Core.Security return Task.FromResult(result); } - /// - public override async Task SetPasswordHashAsync(BackOfficeIdentityUser user, string passwordHash, CancellationToken cancellationToken = default) - { - await base.SetPasswordHashAsync(user, passwordHash, cancellationToken); - - user.PasswordConfig = null; // Clear this so that it's reset at the repository level - user.LastPasswordChangeDateUtc = DateTime.UtcNow; - } - - /// - public override async Task HasPasswordAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default) - { - // This checks if it's null - var result = await base.HasPasswordAsync(user, cancellationToken); - if (result) - { - // we also want to check empty - return string.IsNullOrEmpty(user.PasswordHash) == false; - } - - return result; - } - /// public override Task FindByEmailAsync(string email, CancellationToken cancellationToken = default) { @@ -286,12 +246,13 @@ namespace Umbraco.Cms.Core.Security } /// - public override Task GetNormalizedEmailAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken) - => GetEmailAsync(user, cancellationToken); + public override async Task SetPasswordHashAsync(BackOfficeIdentityUser user, string passwordHash, CancellationToken cancellationToken = default) + { + await base.SetPasswordHashAsync(user, passwordHash, cancellationToken); - /// - public override Task SetNormalizedEmailAsync(BackOfficeIdentityUser user, string normalizedEmail, CancellationToken cancellationToken) - => SetEmailAsync(user, normalizedEmail, cancellationToken); + // Clear this so that it's reset at the repository level + user.PasswordConfig = null; + } /// public override Task AddLoginAsync(BackOfficeIdentityUser user, UserLoginInfo login, CancellationToken cancellationToken = default) @@ -398,100 +359,6 @@ namespace Umbraco.Cms.Core.Security }); } - /// - /// Adds a user to a role (user group) - /// - public override Task AddToRoleAsync(BackOfficeIdentityUser user, string normalizedRoleName, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - ThrowIfDisposed(); - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - if (normalizedRoleName == null) - { - throw new ArgumentNullException(nameof(normalizedRoleName)); - } - - if (string.IsNullOrWhiteSpace(normalizedRoleName)) - { - throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(normalizedRoleName)); - } - - IdentityUserRole userRole = user.Roles.SingleOrDefault(r => r.RoleId == normalizedRoleName); - - if (userRole == null) - { - user.AddRole(normalizedRoleName); - } - - return Task.CompletedTask; - } - - /// - /// Removes the role (user group) for the user - /// - public override Task RemoveFromRoleAsync(BackOfficeIdentityUser user, string normalizedRoleName, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - if (normalizedRoleName == null) - { - throw new ArgumentNullException(nameof(normalizedRoleName)); - } - - if (string.IsNullOrWhiteSpace(normalizedRoleName)) - { - throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(normalizedRoleName)); - } - - IdentityUserRole userRole = user.Roles.SingleOrDefault(r => r.RoleId == normalizedRoleName); - - if (userRole != null) - { - user.Roles.Remove(userRole); - } - - return Task.CompletedTask; - } - - /// - /// Gets a list of role names the specified user belongs to. - /// - public override Task> GetRolesAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - ThrowIfDisposed(); - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - return Task.FromResult((IList)user.Roles.Select(x => x.RoleId).ToList()); - } - - /// - /// Returns true if a user is in the role - /// - public override Task IsInRoleAsync(BackOfficeIdentityUser user, string normalizedRoleName, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - ThrowIfDisposed(); - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - return Task.FromResult(user.Roles.Select(x => x.RoleId).InvariantContains(normalizedRoleName)); - } - /// /// Lists all users of a given role. /// @@ -543,22 +410,6 @@ namespace Umbraco.Cms.Core.Security return found; } - /// - public override Task GetSecurityStampAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - ThrowIfDisposed(); - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - // the stamp cannot be null, so if it is currently null then we'll just return a hash of the password - return Task.FromResult(user.SecurityStamp.IsNullOrWhiteSpace() - ? user.PasswordHash.GenerateHash() - : user.SecurityStamp); - } - private BackOfficeIdentityUser AssignLoginsCallback(BackOfficeIdentityUser user) { if (user != null) @@ -678,36 +529,26 @@ namespace Umbraco.Cms.Core.Security user.SecurityStamp = identityUser.SecurityStamp; } - // TODO: Fix this for Groups too - if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.Roles)) || identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.Groups))) + if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.Roles))) { - var userGroupAliases = user.Groups.Select(x => x.Alias).ToArray(); - var identityUserRoles = identityUser.Roles.Select(x => x.RoleId).ToArray(); - var identityUserGroups = identityUser.Groups.Select(x => x.Alias).ToArray(); - var combinedAliases = identityUserRoles.Union(identityUserGroups).ToArray(); + anythingChanged = true; - if (userGroupAliases.ContainsAll(combinedAliases) == false - || combinedAliases.ContainsAll(userGroupAliases) == false) + // clear out the current groups (need to ToArray since we are modifying the iterator) + user.ClearGroups(); + + // go lookup all these groups + IReadOnlyUserGroup[] groups = _userService.GetUserGroupsByAlias(identityUserRoles).Select(x => x.ToReadOnlyGroup()).ToArray(); + + // use all of the ones assigned and add them + foreach (IReadOnlyUserGroup group in groups) { - anythingChanged = true; - - // clear out the current groups (need to ToArray since we are modifying the iterator) - user.ClearGroups(); - - // go lookup all these groups - var groups = _userService.GetUserGroupsByAlias(combinedAliases).Select(x => x.ToReadOnlyGroup()).ToArray(); - - // use all of the ones assigned and add them - foreach (var group in groups) - { - user.AddGroup(group); - } - - // re-assign - identityUser.Groups = groups; + user.AddGroup(group); } + + // re-assign + identityUser.SetGroups(groups); } // we should re-set the calculated start nodes @@ -731,54 +572,6 @@ namespace Umbraco.Cms.Core.Security return Task.FromResult(false); } - private static int UserIdToInt(string userId) - { - Attempt attempt = userId.TryConvertTo(); - if (attempt.Success) - { - return attempt.Result; - } - - throw new InvalidOperationException("Unable to convert user ID to int", attempt.Exception); - } - - private static string UserIdToString(int userId) => string.Intern(userId.ToString()); - - /// - /// Not supported in Umbraco - /// - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public override Task> GetClaimsAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - - /// - /// Not supported in Umbraco - /// - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public override Task AddClaimsAsync(BackOfficeIdentityUser user, IEnumerable claims, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - - /// - /// Not supported in Umbraco - /// - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public override Task ReplaceClaimAsync(BackOfficeIdentityUser user, Claim claim, Claim newClaim, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - - /// - /// Not supported in Umbraco - /// - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public override Task RemoveClaimsAsync(BackOfficeIdentityUser user, IEnumerable claims, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - - /// - /// Not supported in Umbraco - /// - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public override Task> GetUsersForClaimAsync(Claim claim, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - /// /// Overridden to support Umbraco's own data storage requirements @@ -859,25 +652,5 @@ namespace Umbraco.Cms.Core.Security return Task.FromResult(token?.Value); } - - /// - /// Not supported in Umbraco, see comments above on GetTokenAsync, RemoveTokenAsync, SetTokenAsync - /// - /// - protected override Task> FindTokenAsync(BackOfficeIdentityUser user, string loginProvider, string name, CancellationToken cancellationToken) => throw new NotImplementedException(); - - /// - /// Not supported in Umbraco, see comments above on GetTokenAsync, RemoveTokenAsync, SetTokenAsync - /// - /// - [EditorBrowsable(EditorBrowsableState.Never)] - protected override Task AddUserTokenAsync(IdentityUserToken token) => throw new NotImplementedException(); - - /// - /// Not supported in Umbraco, see comments above on GetTokenAsync, RemoveTokenAsync, SetTokenAsync - /// - /// - [EditorBrowsable(EditorBrowsableState.Never)] - protected override Task RemoveUserTokenAsync(IdentityUserToken token) => throw new NotImplementedException(); } } diff --git a/src/Umbraco.Infrastructure/Security/ClaimsIdentityExtensions.cs b/src/Umbraco.Infrastructure/Security/ClaimsIdentityExtensions.cs index d4b61a934d..de5b6206fc 100644 --- a/src/Umbraco.Infrastructure/Security/ClaimsIdentityExtensions.cs +++ b/src/Umbraco.Infrastructure/Security/ClaimsIdentityExtensions.cs @@ -22,7 +22,7 @@ namespace Umbraco.Extensions } } - public static void MergeClaimsFromBackOfficeIdentity(this ClaimsIdentity destination, ClaimsIdentity source) + public static void MergeClaimsFromCookieIdentity(this ClaimsIdentity destination, ClaimsIdentity source) { foreach (Claim claim in source.Claims .Where(claim => !s_ignoredClaims.Contains(claim.Type)) diff --git a/src/Umbraco.Infrastructure/Security/IdentityMapDefinition.cs b/src/Umbraco.Infrastructure/Security/IdentityMapDefinition.cs index 70574181e0..5c7332ebcc 100644 --- a/src/Umbraco.Infrastructure/Security/IdentityMapDefinition.cs +++ b/src/Umbraco.Infrastructure/Security/IdentityMapDefinition.cs @@ -100,6 +100,7 @@ namespace Umbraco.Cms.Core.Security //target.Roles =; } + // TODO: We need to validate this mapping is OK, we need to get Umbraco.Code working private void Map(IMember source, MemberIdentityUser target) { target.Email = source.Email; diff --git a/src/Umbraco.Infrastructure/Security/MemberIdentityUser.cs b/src/Umbraco.Infrastructure/Security/MemberIdentityUser.cs index 3cfe779d10..f49d21203d 100644 --- a/src/Umbraco.Infrastructure/Security/MemberIdentityUser.cs +++ b/src/Umbraco.Infrastructure/Security/MemberIdentityUser.cs @@ -12,7 +12,7 @@ namespace Umbraco.Cms.Core.Security /// public class MemberIdentityUser : UmbracoIdentityUser { - private string _comments; + private string _comments; private IReadOnlyCollection _groups; // Custom comparer for enumerables diff --git a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs index c752e41bb2..f63338383f 100644 --- a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs @@ -18,7 +18,7 @@ namespace Umbraco.Cms.Core.Security /// /// A custom user store that uses Umbraco member data /// - public class MemberUserStore : UserStoreBase, IdentityUserRole, IdentityUserLogin, IdentityUserToken, IdentityRoleClaim> + public class MemberUserStore : UmbracoUserStore { private const string genericIdentityErrorCode = "IdentityErrorUserStore"; private readonly IMemberService _memberService; @@ -32,7 +32,11 @@ namespace Umbraco.Cms.Core.Security /// The mapper for properties /// The scope provider /// The error describer - public MemberUserStore(IMemberService memberService, UmbracoMapper mapper, IScopeProvider scopeProvider, IdentityErrorDescriber describer) + public MemberUserStore( + IMemberService memberService, + UmbracoMapper mapper, + IScopeProvider scopeProvider, + IdentityErrorDescriber describer) : base(describer) { _memberService = memberService ?? throw new ArgumentNullException(nameof(memberService)); @@ -40,22 +44,6 @@ namespace Umbraco.Cms.Core.Security _scopeProvider = scopeProvider ?? throw new ArgumentNullException(nameof(scopeProvider)); } - //TODO: why is this not supported? - /// - /// Not supported in Umbraco - /// - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public override IQueryable Users => throw new NotImplementedException(); - - /// - public override Task GetNormalizedUserNameAsync(MemberIdentityUser user, CancellationToken cancellationToken = default) - => GetUserNameAsync(user, cancellationToken); - - /// - public override Task SetNormalizedUserNameAsync(MemberIdentityUser user, string normalizedName, CancellationToken cancellationToken = default) - => SetUserNameAsync(user, normalizedName, cancellationToken); - /// public override Task CreateAsync(MemberIdentityUser user, CancellationToken cancellationToken = default) { @@ -68,6 +56,8 @@ namespace Umbraco.Cms.Core.Security throw new ArgumentNullException(nameof(user)); } + using IScope scope = _scopeProvider.CreateScope(autoComplete: true); + // create member IMember memberEntity = _memberService.CreateMember( user.UserName, @@ -130,36 +120,33 @@ namespace Umbraco.Cms.Core.Security throw new InvalidOperationException("The user id must be an integer to work with Umbraco"); } - using (IScope scope = _scopeProvider.CreateScope()) + using IScope scope = _scopeProvider.CreateScope(autoComplete: true); + + IMember found = _memberService.GetById(asInt.Result); + if (found != null) { - IMember found = _memberService.GetById(asInt.Result); - if (found != null) + // we have to remember whether Logins property is dirty, since the UpdateMemberProperties will reset it. + var isLoginsPropertyDirty = user.IsPropertyDirty(nameof(MemberIdentityUser.Logins)); + + if (UpdateMemberProperties(found, user)) { - // we have to remember whether Logins property is dirty, since the UpdateMemberProperties will reset it. - var isLoginsPropertyDirty = user.IsPropertyDirty(nameof(MemberIdentityUser.Logins)); - - if (UpdateMemberProperties(found, user)) - { - _memberService.Save(found); - } - - // TODO: when to implement external login service? - - //if (isLoginsPropertyDirty) - //{ - // _externalLoginService.Save( - // found.Id, - // user.Logins.Select(x => new ExternalLogin( - // x.LoginProvider, - // x.ProviderKey, - // x.UserData))); - //} + _memberService.Save(found); } - scope.Complete(); + // TODO: when to implement external login service? - return Task.FromResult(IdentityResult.Success); + //if (isLoginsPropertyDirty) + //{ + // _externalLoginService.Save( + // found.Id, + // user.Logins.Select(x => new ExternalLogin( + // x.LoginProvider, + // x.ProviderKey, + // x.UserData))); + //} } + + return Task.FromResult(IdentityResult.Success); } catch (Exception ex) { @@ -196,9 +183,6 @@ namespace Umbraco.Cms.Core.Security } } - /// - public override Task FindByIdAsync(string userId, CancellationToken cancellationToken = default) => FindUserAsync(userId, cancellationToken); - /// protected override Task FindUserAsync(string userId, CancellationToken cancellationToken) { @@ -235,30 +219,6 @@ namespace Umbraco.Cms.Core.Security return Task.FromResult(result); } - /// - public override async Task SetPasswordHashAsync(MemberIdentityUser user, string passwordHash, CancellationToken cancellationToken = default) - { - await base.SetPasswordHashAsync(user, passwordHash, cancellationToken); - - // Clear this so that it's reset at the repository level - user.PasswordConfig = null; - user.LastPasswordChangeDateUtc = DateTime.UtcNow; - } - - /// - public override async Task HasPasswordAsync(MemberIdentityUser user, CancellationToken cancellationToken = default) - { - // This checks if it's null - bool result = await base.HasPasswordAsync(user, cancellationToken); - if (result) - { - // we also want to check empty - return string.IsNullOrEmpty(user.PasswordHash) == false; - } - - return false; - } - /// public override Task FindByEmailAsync(string email, CancellationToken cancellationToken = default) { @@ -272,14 +232,6 @@ namespace Umbraco.Cms.Core.Security return Task.FromResult(AssignLoginsCallback(result)); } - /// - public override Task GetNormalizedEmailAsync(MemberIdentityUser user, CancellationToken cancellationToken) - => GetEmailAsync(user, cancellationToken); - - /// - public override Task SetNormalizedEmailAsync(MemberIdentityUser user, string normalizedEmail, CancellationToken cancellationToken) - => SetEmailAsync(user, normalizedEmail, cancellationToken); - /// public override Task AddLoginAsync(MemberIdentityUser user, UserLoginInfo login, CancellationToken cancellationToken = default) { @@ -434,93 +386,33 @@ namespace Umbraco.Cms.Core.Security }); } - /// - public override Task AddToRoleAsync(MemberIdentityUser user, string role, CancellationToken cancellationToken = default) - { - if (cancellationToken != null) - { - cancellationToken.ThrowIfCancellationRequested(); - } - - ThrowIfDisposed(); - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - if (role == null) - { - throw new ArgumentNullException(nameof(role)); - } - - if (string.IsNullOrWhiteSpace(role)) - { - throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(role)); - } - - IdentityUserRole userRole = user.Roles.SingleOrDefault(r => r.RoleId == role); - - if (userRole == null) - { - _memberService.AssignRole(user.UserName, role); - user.AddRole(role); - } - - return Task.CompletedTask; - } - - /// - public override Task RemoveFromRoleAsync(MemberIdentityUser user, string role, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - if (role == null) - { - throw new ArgumentNullException(nameof(role)); - } - - if (string.IsNullOrWhiteSpace(role)) - { - throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(role)); - } - - IdentityUserRole userRole = user.Roles.SingleOrDefault(r => r.RoleId == role); - - if (userRole != null) - { - _memberService.DissociateRole(user.UserName, userRole.RoleId); - user.Roles.Remove(userRole); - } - - return Task.CompletedTask; - } - /// /// Gets a list of role names the specified user belongs to. /// + /// + /// This lazy loads the roles for the member + /// public override Task> GetRolesAsync(MemberIdentityUser user, CancellationToken cancellationToken = default) { - cancellationToken.ThrowIfCancellationRequested(); - ThrowIfDisposed(); - if (user == null) + EnsureRoles(user); + return base.GetRolesAsync(user, cancellationToken); + } + + private void EnsureRoles(MemberIdentityUser user) + { + if (user.Roles.Count == 0) { - throw new ArgumentNullException(nameof(user)); + // if there are no roles, they either haven't been loaded since we don't eagerly + // load for members, or they just have no roles. + IEnumerable currentRoles = _memberService.GetAllRoles(user.UserName); + ICollection> roles = currentRoles.Select(role => new IdentityUserRole + { + RoleId = role, + UserId = user.Id + }).ToList(); + + user.Roles = roles; } - - IEnumerable currentRoles = _memberService.GetAllRoles(user.UserName); - ICollection> roles = currentRoles.Select(role => new IdentityUserRole - { - RoleId = role, - UserId = user.Id - }).ToList(); - - user.Roles = roles; - return Task.FromResult((IList)user.Roles.Select(x => x.RoleId).ToList()); } /// @@ -528,19 +420,9 @@ namespace Umbraco.Cms.Core.Security /// public override Task IsInRoleAsync(MemberIdentityUser user, string roleName, CancellationToken cancellationToken = default) { - cancellationToken.ThrowIfCancellationRequested(); - ThrowIfDisposed(); - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } + EnsureRoles(user); - if (string.IsNullOrWhiteSpace(roleName)) - { - throw new ArgumentNullException(nameof(roleName)); - } - - return Task.FromResult(user.Roles.Select(x => x.RoleId).InvariantContains(roleName)); + return base.IsInRoleAsync(user, roleName, cancellationToken); } /// @@ -597,22 +479,6 @@ namespace Umbraco.Cms.Core.Security return found; } - /// - public override Task GetSecurityStampAsync(MemberIdentityUser user, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - ThrowIfDisposed(); - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - // the stamp cannot be null, so if it is currently null then we'll just return a hash of the password - return Task.FromResult(user.SecurityStamp.IsNullOrWhiteSpace() - ? user.PasswordHash.GenerateHash() - : user.SecurityStamp); - } - private MemberIdentityUser AssignLoginsCallback(MemberIdentityUser user) { if (user != null) @@ -624,70 +490,70 @@ namespace Umbraco.Cms.Core.Security return user; } - private bool UpdateMemberProperties(IMember member, MemberIdentityUser identityUserMember) + private bool UpdateMemberProperties(IMember member, MemberIdentityUser identityUser) { var anythingChanged = false; // don't assign anything if nothing has changed as this will trigger the track changes of the model - if (identityUserMember.IsPropertyDirty(nameof(MemberIdentityUser.LastLoginDateUtc)) - || (member.LastLoginDate != default && identityUserMember.LastLoginDateUtc.HasValue == false) - || (identityUserMember.LastLoginDateUtc.HasValue && member.LastLoginDate.ToUniversalTime() != identityUserMember.LastLoginDateUtc.Value)) + if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.LastLoginDateUtc)) + || (member.LastLoginDate != default && identityUser.LastLoginDateUtc.HasValue == false) + || (identityUser.LastLoginDateUtc.HasValue && member.LastLoginDate.ToUniversalTime() != identityUser.LastLoginDateUtc.Value)) { anythingChanged = true; // if the LastLoginDate is being set to MinValue, don't convert it ToLocalTime - DateTime dt = identityUserMember.LastLoginDateUtc == DateTime.MinValue ? DateTime.MinValue : identityUserMember.LastLoginDateUtc.Value.ToLocalTime(); + DateTime dt = identityUser.LastLoginDateUtc == DateTime.MinValue ? DateTime.MinValue : identityUser.LastLoginDateUtc.Value.ToLocalTime(); member.LastLoginDate = dt; } - if (identityUserMember.IsPropertyDirty(nameof(MemberIdentityUser.LastPasswordChangeDateUtc)) - || (member.LastPasswordChangeDate != default && identityUserMember.LastPasswordChangeDateUtc.HasValue == false) - || (identityUserMember.LastPasswordChangeDateUtc.HasValue && member.LastPasswordChangeDate.ToUniversalTime() != identityUserMember.LastPasswordChangeDateUtc.Value)) + if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.LastPasswordChangeDateUtc)) + || (member.LastPasswordChangeDate != default && identityUser.LastPasswordChangeDateUtc.HasValue == false) + || (identityUser.LastPasswordChangeDateUtc.HasValue && member.LastPasswordChangeDate.ToUniversalTime() != identityUser.LastPasswordChangeDateUtc.Value)) { anythingChanged = true; - member.LastPasswordChangeDate = identityUserMember.LastPasswordChangeDateUtc.Value.ToLocalTime(); + member.LastPasswordChangeDate = identityUser.LastPasswordChangeDateUtc.Value.ToLocalTime(); } - if (identityUserMember.IsPropertyDirty(nameof(MemberIdentityUser.Comments)) - && member.Comments != identityUserMember.Comments && identityUserMember.Comments.IsNullOrWhiteSpace() == false) + if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.Comments)) + && member.Comments != identityUser.Comments && identityUser.Comments.IsNullOrWhiteSpace() == false) { anythingChanged = true; - member.Comments = identityUserMember.Comments; + member.Comments = identityUser.Comments; } - if (identityUserMember.IsPropertyDirty(nameof(MemberIdentityUser.EmailConfirmed)) - || (member.EmailConfirmedDate.HasValue && member.EmailConfirmedDate.Value != default && identityUserMember.EmailConfirmed == false) - || ((member.EmailConfirmedDate.HasValue == false || member.EmailConfirmedDate.Value == default) && identityUserMember.EmailConfirmed)) + if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.EmailConfirmed)) + || (member.EmailConfirmedDate.HasValue && member.EmailConfirmedDate.Value != default && identityUser.EmailConfirmed == false) + || ((member.EmailConfirmedDate.HasValue == false || member.EmailConfirmedDate.Value == default) && identityUser.EmailConfirmed)) { anythingChanged = true; - member.EmailConfirmedDate = identityUserMember.EmailConfirmed ? (DateTime?)DateTime.Now : null; + member.EmailConfirmedDate = identityUser.EmailConfirmed ? (DateTime?)DateTime.Now : null; } - if (identityUserMember.IsPropertyDirty(nameof(MemberIdentityUser.Name)) - && member.Name != identityUserMember.Name && identityUserMember.Name.IsNullOrWhiteSpace() == false) + if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.Name)) + && member.Name != identityUser.Name && identityUser.Name.IsNullOrWhiteSpace() == false) { anythingChanged = true; - member.Name = identityUserMember.Name; + member.Name = identityUser.Name; } - if (identityUserMember.IsPropertyDirty(nameof(MemberIdentityUser.Email)) - && member.Email != identityUserMember.Email && identityUserMember.Email.IsNullOrWhiteSpace() == false) + if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.Email)) + && member.Email != identityUser.Email && identityUser.Email.IsNullOrWhiteSpace() == false) { anythingChanged = true; - member.Email = identityUserMember.Email; + member.Email = identityUser.Email; } - if (identityUserMember.IsPropertyDirty(nameof(MemberIdentityUser.AccessFailedCount)) - && member.FailedPasswordAttempts != identityUserMember.AccessFailedCount) + if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.AccessFailedCount)) + && member.FailedPasswordAttempts != identityUser.AccessFailedCount) { anythingChanged = true; - member.FailedPasswordAttempts = identityUserMember.AccessFailedCount; + member.FailedPasswordAttempts = identityUser.AccessFailedCount; } - if (member.IsLockedOut != identityUserMember.IsLockedOut) + if (member.IsLockedOut != identityUser.IsLockedOut) { anythingChanged = true; - member.IsLockedOut = identityUserMember.IsLockedOut; + member.IsLockedOut = identityUser.IsLockedOut; if (member.IsLockedOut) { @@ -696,112 +562,46 @@ namespace Umbraco.Cms.Core.Security } } - if (member.IsApproved != identityUserMember.IsApproved) + if (member.IsApproved != identityUser.IsApproved) { anythingChanged = true; - member.IsApproved = identityUserMember.IsApproved; + member.IsApproved = identityUser.IsApproved; } - if (identityUserMember.IsPropertyDirty(nameof(MemberIdentityUser.UserName)) - && member.Username != identityUserMember.UserName && identityUserMember.UserName.IsNullOrWhiteSpace() == false) + if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.UserName)) + && member.Username != identityUser.UserName && identityUser.UserName.IsNullOrWhiteSpace() == false) { anythingChanged = true; - member.Username = identityUserMember.UserName; + member.Username = identityUser.UserName; } - if (identityUserMember.IsPropertyDirty(nameof(MemberIdentityUser.PasswordHash)) - && member.RawPasswordValue != identityUserMember.PasswordHash && identityUserMember.PasswordHash.IsNullOrWhiteSpace() == false) + if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.PasswordHash)) + && member.RawPasswordValue != identityUser.PasswordHash && identityUser.PasswordHash.IsNullOrWhiteSpace() == false) { anythingChanged = true; - member.RawPasswordValue = identityUserMember.PasswordHash; - member.PasswordConfiguration = identityUserMember.PasswordConfig; + member.RawPasswordValue = identityUser.PasswordHash; + member.PasswordConfiguration = identityUser.PasswordConfig; } - if (member.SecurityStamp != identityUserMember.SecurityStamp) + if (member.SecurityStamp != identityUser.SecurityStamp) { anythingChanged = true; - member.SecurityStamp = identityUserMember.SecurityStamp; + member.SecurityStamp = identityUser.SecurityStamp; } - // TODO: Fix this for Groups too (as per backoffice comment) - if (identityUserMember.IsPropertyDirty(nameof(MemberIdentityUser.Roles)) || identityUserMember.IsPropertyDirty(nameof(MemberIdentityUser.Groups))) + if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.Roles))) { + anythingChanged = true; + + var identityUserRoles = identityUser.Roles.Select(x => x.RoleId).ToArray(); + _memberService.ReplaceRoles(new[] { member.Id }, identityUserRoles); } // reset all changes - identityUserMember.ResetDirtyProperties(false); + identityUser.ResetDirtyProperties(false); return anythingChanged; } - private static int UserIdToInt(string userId) - { - Attempt attempt = userId.TryConvertTo(); - if (attempt.Success) - { - return attempt.Result; - } - - throw new InvalidOperationException("Unable to convert user ID to int", attempt.Exception); - } - - private static string UserIdToString(int userId) => string.Intern(userId.ToString()); - - /// - /// Not supported in Umbraco - /// - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public override Task> GetClaimsAsync(MemberIdentityUser user, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - - /// - /// Not supported in Umbraco - /// - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public override Task AddClaimsAsync(MemberIdentityUser user, IEnumerable claims, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - - /// - /// Not supported in Umbraco - /// - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public override Task ReplaceClaimAsync(MemberIdentityUser user, Claim claim, Claim newClaim, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - - /// - /// Not supported in Umbraco - /// - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public override Task RemoveClaimsAsync(MemberIdentityUser user, IEnumerable claims, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - - /// - /// Not supported in Umbraco - /// - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public override Task> GetUsersForClaimAsync(Claim claim, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - - /// - /// Not supported in Umbraco - /// - /// - [EditorBrowsable(EditorBrowsableState.Never)] - protected override Task> FindTokenAsync(MemberIdentityUser user, string loginProvider, string name, CancellationToken cancellationToken) => throw new NotImplementedException(); - - /// - /// Not supported in Umbraco - /// - /// - [EditorBrowsable(EditorBrowsableState.Never)] - protected override Task AddUserTokenAsync(IdentityUserToken token) => throw new NotImplementedException(); - - /// - /// Not supported in Umbraco - /// - /// - [EditorBrowsable(EditorBrowsableState.Never)] - protected override Task RemoveUserTokenAsync(IdentityUserToken token) => throw new NotImplementedException(); - } } diff --git a/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs b/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs new file mode 100644 index 0000000000..1db823cc78 --- /dev/null +++ b/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Security +{ + public abstract class UmbracoUserStore : UserStoreBase, IdentityUserRole, IdentityUserLogin, IdentityUserToken, IdentityRoleClaim> + where TUser : UmbracoIdentityUser + where TRole : IdentityRole + { + protected UmbracoUserStore(IdentityErrorDescriber describer) : base(describer) + { + } + + /// + /// Not supported in Umbraco + /// + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public override IQueryable Users => throw new NotImplementedException(); + + protected static int UserIdToInt(string userId) + { + Attempt attempt = userId.TryConvertTo(); + if (attempt.Success) + { + return attempt.Result; + } + + throw new InvalidOperationException("Unable to convert user ID to int", attempt.Exception); + } + + protected static string UserIdToString(int userId) => string.Intern(userId.ToString()); + + /// + /// Not supported in Umbraco + /// + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public override Task AddClaimsAsync(TUser user, IEnumerable claims, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + + /// + /// Adds a user to a role (user group) + /// + public override Task AddToRoleAsync(TUser user, string normalizedRoleName, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + if (normalizedRoleName == null) + { + throw new ArgumentNullException(nameof(normalizedRoleName)); + } + + if (string.IsNullOrWhiteSpace(normalizedRoleName)) + { + throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(normalizedRoleName)); + } + + IdentityUserRole userRole = user.Roles.SingleOrDefault(r => r.RoleId == normalizedRoleName); + + if (userRole == null) + { + user.AddRole(normalizedRoleName); + } + + return Task.CompletedTask; + } + + /// + public override Task FindByIdAsync(string userId, CancellationToken cancellationToken = default) => FindUserAsync(userId, cancellationToken); + + /// + /// Not supported in Umbraco + /// + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public override Task> GetClaimsAsync(TUser user, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + + /// + public override Task GetNormalizedEmailAsync(TUser user, CancellationToken cancellationToken) + => GetEmailAsync(user, cancellationToken); + + /// + public override Task GetNormalizedUserNameAsync(TUser user, CancellationToken cancellationToken = default) + => GetUserNameAsync(user, cancellationToken); + + /// + /// Gets a list of role names the specified user belongs to. + /// + public override Task> GetRolesAsync(TUser user, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + return Task.FromResult((IList)user.Roles.Select(x => x.RoleId).ToList()); + } + + /// + public override Task GetSecurityStampAsync(TUser user, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + // the stamp cannot be null, so if it is currently null then we'll just return a hash of the password + return Task.FromResult(user.SecurityStamp.IsNullOrWhiteSpace() + ? user.PasswordHash.GenerateHash() + : user.SecurityStamp); + } + + /// + /// Not supported in Umbraco + /// + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public override Task> GetUsersForClaimAsync(Claim claim, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + + /// + public override async Task HasPasswordAsync(TUser user, CancellationToken cancellationToken = default) + { + // This checks if it's null + bool result = await base.HasPasswordAsync(user, cancellationToken); + if (result) + { + // we also want to check empty + return string.IsNullOrEmpty(user.PasswordHash) == false; + } + + return false; + } + + /// + /// Returns true if a user is in the role + /// + public override Task IsInRoleAsync(TUser user, string normalizedRoleName, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + return Task.FromResult(user.Roles.Select(x => x.RoleId).InvariantContains(normalizedRoleName)); + } + + /// + /// Not supported in Umbraco + /// + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public override Task RemoveClaimsAsync(TUser user, IEnumerable claims, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + + /// + /// Removes the role (user group) for the user + /// + public override Task RemoveFromRoleAsync(TUser user, string normalizedRoleName, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + if (normalizedRoleName == null) + { + throw new ArgumentNullException(nameof(normalizedRoleName)); + } + + if (string.IsNullOrWhiteSpace(normalizedRoleName)) + { + throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(normalizedRoleName)); + } + + IdentityUserRole userRole = user.Roles.SingleOrDefault(r => r.RoleId == normalizedRoleName); + + if (userRole != null) + { + user.Roles.Remove(userRole); + } + + return Task.CompletedTask; + } + + /// + /// Not supported in Umbraco + /// + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public override Task ReplaceClaimAsync(TUser user, Claim claim, Claim newClaim, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + + /// + public override Task SetNormalizedEmailAsync(TUser user, string normalizedEmail, CancellationToken cancellationToken) + => SetEmailAsync(user, normalizedEmail, cancellationToken); + + /// + public override Task SetNormalizedUserNameAsync(TUser user, string normalizedName, CancellationToken cancellationToken = default) + => SetUserNameAsync(user, normalizedName, cancellationToken); + + /// + public override async Task SetPasswordHashAsync(TUser user, string passwordHash, CancellationToken cancellationToken = default) + { + await base.SetPasswordHashAsync(user, passwordHash, cancellationToken); + user.LastPasswordChangeDateUtc = DateTime.UtcNow; + } + + /// + /// Not supported in Umbraco, see comments above on GetTokenAsync, RemoveTokenAsync, SetTokenAsync + /// + /// + [EditorBrowsable(EditorBrowsableState.Never)] + protected override Task AddUserTokenAsync(IdentityUserToken token) => throw new NotImplementedException(); + + /// + /// Not supported in Umbraco, see comments above on GetTokenAsync, RemoveTokenAsync, SetTokenAsync + /// + /// + [EditorBrowsable(EditorBrowsableState.Never)] + protected override Task> FindTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken) => throw new NotImplementedException(); + + /// + /// Not supported in Umbraco, see comments above on GetTokenAsync, RemoveTokenAsync, SetTokenAsync + /// + /// + [EditorBrowsable(EditorBrowsableState.Never)] + protected override Task RemoveUserTokenAsync(IdentityUserToken token) => throw new NotImplementedException(); + } +} diff --git a/src/Umbraco.Infrastructure/Services/Implement/MemberService.cs b/src/Umbraco.Infrastructure/Services/Implement/MemberService.cs index 959ffc3239..78f6d410a0 100644 --- a/src/Umbraco.Infrastructure/Services/Implement/MemberService.cs +++ b/src/Umbraco.Infrastructure/Services/Implement/MemberService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Microsoft.Extensions.Logging; @@ -1015,7 +1015,7 @@ namespace Umbraco.Cms.Core.Services.Implement using (IScope scope = ScopeProvider.CreateScope()) { scope.WriteLock(Constants.Locks.MemberTree); - int[] ids = _memberGroupRepository.GetMemberIds(usernames); + int[] ids = _memberRepository.GetMemberIds(usernames); _memberGroupRepository.AssignRoles(ids, roleNames); scope.Notifications.Publish(new AssignedMemberRolesNotification(ids, roleNames)); scope.Complete(); @@ -1029,7 +1029,7 @@ namespace Umbraco.Cms.Core.Services.Implement using (IScope scope = ScopeProvider.CreateScope()) { scope.WriteLock(Constants.Locks.MemberTree); - int[] ids = _memberGroupRepository.GetMemberIds(usernames); + int[] ids = _memberRepository.GetMemberIds(usernames); _memberGroupRepository.DissociateRoles(ids, roleNames); scope.Notifications.Publish(new RemovedMemberRolesNotification(ids, roleNames)); scope.Complete(); @@ -1062,6 +1062,29 @@ namespace Umbraco.Cms.Core.Services.Implement } } + public void ReplaceRoles(string[] usernames, string[] roleNames) + { + using (IScope scope = ScopeProvider.CreateScope()) + { + scope.WriteLock(Constants.Locks.MemberTree); + int[] ids = _memberRepository.GetMemberIds(usernames); + _memberGroupRepository.ReplaceRoles(ids, roleNames); + scope.Notifications.Publish(new AssignedMemberRolesNotification(ids, roleNames)); + scope.Complete(); + } + } + + public void ReplaceRoles(int[] memberIds, string[] roleNames) + { + using (IScope scope = ScopeProvider.CreateScope()) + { + scope.WriteLock(Constants.Locks.MemberTree); + _memberGroupRepository.ReplaceRoles(memberIds, roleNames); + scope.Notifications.Publish(new AssignedMemberRolesNotification(memberIds, roleNames)); + scope.Complete(); + } + } + #endregion #region Private Methods diff --git a/src/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberGroupServiceTests.cs b/src/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberGroupServiceTests.cs deleted file mode 100644 index ef876f04eb..0000000000 --- a/src/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberGroupServiceTests.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Umbraco. -// See LICENSE for more details. - -using System; -using System.Threading; -using NUnit.Framework; -using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Tests.Common.Builders; -using Umbraco.Cms.Tests.Common.Builders.Extensions; -using Umbraco.Cms.Tests.Common.Testing; -using Umbraco.Cms.Tests.Integration.Testing; - -namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services -{ - [TestFixture] - [Apartment(ApartmentState.STA)] - [UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, PublishedRepositoryEvents = true)] - public class MemberGroupServiceTests : UmbracoIntegrationTest - { - private IMemberGroupService MemberGroupService => GetRequiredService(); - - /// - /// Used to list out all ambiguous events that will require dispatching with a name - /// - [Test] - [Explicit] - public void List_Ambiguous_Events() - { - MemberGroup memberGroup = new MemberGroupBuilder() - .WithName(string.Empty) - .Build(); - Assert.Throws(() => MemberGroupService.Save(memberGroup)); - } - } -} diff --git a/src/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberServiceTests.cs b/src/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberServiceTests.cs index 0e6ebe7ebb..445270ea7b 100644 --- a/src/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberServiceTests.cs +++ b/src/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberServiceTests.cs @@ -227,6 +227,25 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services Assert.AreEqual(3, found.Count()); } + [Test] + public void Can_Replace_Roles() + { + IMemberType memberType = MemberTypeBuilder.CreateSimpleMemberType(); + MemberTypeService.Save(memberType); + IMember member = MemberBuilder.CreateSimpleMember(memberType, "test", "test@test.com", "pass", "test"); + MemberService.Save(member); + + string[] roleNames1 = new[] { "TR1", "TR2" }; + MemberService.AssignRoles(new[] { member.Id }, roleNames1); + IEnumerable memberRoles = MemberService.GetAllRoles(member.Id); + CollectionAssert.AreEquivalent(roleNames1, memberRoles); + + string[] roleNames2 = new[] { "TR3", "TR4" }; + MemberService.ReplaceRoles(new[] { member.Id }, roleNames2); + memberRoles = MemberService.GetAllRoles(member.Id); + CollectionAssert.AreEquivalent(roleNames2, memberRoles); + } + [Test] public void Can_Get_All_Roles_By_Member_Id() { diff --git a/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs b/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs index 49b572601e..88c79f4bb1 100644 --- a/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs +++ b/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs @@ -152,9 +152,13 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Security PasswordHash = "abcde", SecurityStamp = "abc" }; + fakeUser.Roles.Add(new IdentityUserRole { RoleId = "role1", UserId = "123" }); + fakeUser.Roles.Add(new IdentityUserRole { RoleId = "role2", UserId = "123" }); + IMemberType fakeMemberType = new MemberType(new MockShortStringHelper(), 77); IMember mockMember = Mock.Of(m => + m.Id == 123 && m.Name == "a" && m.Email == "a@b.com" && m.Username == "c" && @@ -196,6 +200,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Security _mockMemberService.Verify(x => x.Save(mockMember, It.IsAny())); _mockMemberService.Verify(x => x.GetById(123)); + _mockMemberService.Verify(x => x.ReplaceRoles(new[] { 123 }, new[] { "role1", "role2" })); } [Test] diff --git a/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeSecurityStampValidatorOptions.cs b/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeSecurityStampValidatorOptions.cs index 755b89911c..110009e5ef 100644 --- a/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeSecurityStampValidatorOptions.cs +++ b/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeSecurityStampValidatorOptions.cs @@ -1,9 +1,5 @@ -using System; -using System.Linq; -using System.Security.Claims; -using System.Threading.Tasks; using Microsoft.Extensions.Options; -using Umbraco.Extensions; +using Umbraco.Cms.Web.Common.Security; namespace Umbraco.Cms.Web.BackOffice.Security { @@ -13,22 +9,7 @@ namespace Umbraco.Cms.Web.BackOffice.Security public class ConfigureBackOfficeSecurityStampValidatorOptions : IConfigureOptions { public void Configure(BackOfficeSecurityStampValidatorOptions options) - { - options.ValidationInterval = TimeSpan.FromMinutes(30); - - // When refreshing the principal, ensure custom claims that - // might have been set with an external identity continue - // to flow through to this new one. - options.OnRefreshingPrincipal = refreshingPrincipal => - { - ClaimsIdentity newIdentity = refreshingPrincipal.NewPrincipal.Identities.First(); - ClaimsIdentity currentIdentity = refreshingPrincipal.CurrentPrincipal.Identities.First(); - - newIdentity.MergeClaimsFromBackOfficeIdentity(currentIdentity); - - return Task.CompletedTask; - }; - } + => ConfigureSecurityStampOptions.ConfigureOptions(options); } diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.MembersIdentity.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.MembersIdentity.cs index a05b6c84c3..b04141d2b0 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.MembersIdentity.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.MembersIdentity.cs @@ -49,6 +49,8 @@ namespace Umbraco.Extensions services.AddScoped, MemberPasswordHasher>(); + services.ConfigureOptions(); + services.ConfigureApplicationCookie(x => { // TODO: We may want/need to configure these further diff --git a/src/Umbraco.Web.Common/Security/ConfigureSecurityStampOptions.cs b/src/Umbraco.Web.Common/Security/ConfigureSecurityStampOptions.cs new file mode 100644 index 0000000000..03bdf8f4dd --- /dev/null +++ b/src/Umbraco.Web.Common/Security/ConfigureSecurityStampOptions.cs @@ -0,0 +1,39 @@ +using System; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Web.Common.Security +{ + public class ConfigureSecurityStampOptions : IConfigureOptions + { + public void Configure(SecurityStampValidatorOptions options) + => ConfigureOptions(options); + + /// + /// Configures security stamp options and ensures any custom claims + /// set on the identity are persisted to the new identity when it's refreshed. + /// + /// + public static void ConfigureOptions(SecurityStampValidatorOptions options) + { + options.ValidationInterval = TimeSpan.FromMinutes(30); + + // When refreshing the principal, ensure custom claims that + // might have been set with an external identity continue + // to flow through to this new one. + options.OnRefreshingPrincipal = refreshingPrincipal => + { + ClaimsIdentity newIdentity = refreshingPrincipal.NewPrincipal.Identities.First(); + ClaimsIdentity currentIdentity = refreshingPrincipal.CurrentPrincipal.Identities.First(); + + newIdentity.MergeClaimsFromCookieIdentity(currentIdentity); + + return Task.CompletedTask; + }; + } + } +}