diff --git a/framework/src/Volo.Abp.Data/Volo/Abp/Data/DataFilterExtensions.cs b/framework/src/Volo.Abp.Data/Volo/Abp/Data/DataFilterExtensions.cs new file mode 100644 index 0000000000..8123ab7528 --- /dev/null +++ b/framework/src/Volo.Abp.Data/Volo/Abp/Data/DataFilterExtensions.cs @@ -0,0 +1,270 @@ +using System; + +namespace Volo.Abp.Data; + +public static class DataFilterExtensions +{ + private sealed class CompositeDisposable : IDisposable + { + private readonly IDisposable[] _disposables; + private bool _disposed; + + public CompositeDisposable(IDisposable[] disposables) + { + _disposables = disposables; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + foreach (var disposable in _disposables) + { + disposable?.Dispose(); + } + } + } + + public static IDisposable Disable(this IDataFilter filter) + where T1 : class + where T2 : class + { + return new CompositeDisposable(new[] + { + filter.Disable(), + filter.Disable() + }); + } + + public static IDisposable Disable(this IDataFilter filter) + where T1 : class + where T2 : class + where T3 : class + { + return new CompositeDisposable(new[] + { + filter.Disable(), + filter.Disable(), + filter.Disable() + }); + } + + public static IDisposable Disable(this IDataFilter filter) + where T1 : class + where T2 : class + where T3 : class + where T4 : class + { + return new CompositeDisposable(new[] + { + filter.Disable(), + filter.Disable(), + filter.Disable(), + filter.Disable() + }); + } + + public static IDisposable Disable(this IDataFilter filter) + where T1 : class + where T2 : class + where T3 : class + where T4 : class + where T5 : class + { + return new CompositeDisposable(new[] + { + filter.Disable(), + filter.Disable(), + filter.Disable(), + filter.Disable(), + filter.Disable() + }); + } + + public static IDisposable Disable(this IDataFilter filter) + where T1 : class + where T2 : class + where T3 : class + where T4 : class + where T5 : class + where T6 : class + { + return new CompositeDisposable(new[] + { + filter.Disable(), + filter.Disable(), + filter.Disable(), + filter.Disable(), + filter.Disable(), + filter.Disable() + }); + } + + public static IDisposable Disable(this IDataFilter filter) + where T1 : class + where T2 : class + where T3 : class + where T4 : class + where T5 : class + where T6 : class + where T7 : class + { + return new CompositeDisposable(new[] + { + filter.Disable(), + filter.Disable(), + filter.Disable(), + filter.Disable(), + filter.Disable(), + filter.Disable(), + filter.Disable() + }); + } + + public static IDisposable Disable(this IDataFilter filter) + where T1 : class + where T2 : class + where T3 : class + where T4 : class + where T5 : class + where T6 : class + where T7 : class + where T8 : class + { + return new CompositeDisposable(new[] + { + filter.Disable(), + filter.Disable(), + filter.Disable(), + filter.Disable(), + filter.Disable(), + filter.Disable(), + filter.Disable(), + filter.Disable() + }); + } + + public static IDisposable Enable(this IDataFilter filter) + where T1 : class + where T2 : class + { + return new CompositeDisposable(new[] + { + filter.Enable(), + filter.Enable() + }); + } + + public static IDisposable Enable(this IDataFilter filter) + where T1 : class + where T2 : class + where T3 : class + { + return new CompositeDisposable(new[] + { + filter.Enable(), + filter.Enable(), + filter.Enable() + }); + } + + public static IDisposable Enable(this IDataFilter filter) + where T1 : class + where T2 : class + where T3 : class + where T4 : class + { + return new CompositeDisposable(new[] + { + filter.Enable(), + filter.Enable(), + filter.Enable(), + filter.Enable() + }); + } + + public static IDisposable Enable(this IDataFilter filter) + where T1 : class + where T2 : class + where T3 : class + where T4 : class + where T5 : class + { + return new CompositeDisposable(new[] + { + filter.Enable(), + filter.Enable(), + filter.Enable(), + filter.Enable(), + filter.Enable() + }); + } + + public static IDisposable Enable(this IDataFilter filter) + where T1 : class + where T2 : class + where T3 : class + where T4 : class + where T5 : class + where T6 : class + { + return new CompositeDisposable(new[] + { + filter.Enable(), + filter.Enable(), + filter.Enable(), + filter.Enable(), + filter.Enable(), + filter.Enable() + }); + } + + public static IDisposable Enable(this IDataFilter filter) + where T1 : class + where T2 : class + where T3 : class + where T4 : class + where T5 : class + where T6 : class + where T7 : class + { + return new CompositeDisposable(new[] + { + filter.Enable(), + filter.Enable(), + filter.Enable(), + filter.Enable(), + filter.Enable(), + filter.Enable(), + filter.Enable() + }); + } + + public static IDisposable Enable(this IDataFilter filter) + where T1 : class + where T2 : class + where T3 : class + where T4 : class + where T5 : class + where T6 : class + where T7 : class + where T8 : class + { + return new CompositeDisposable(new[] + { + filter.Enable(), + filter.Enable(), + filter.Enable(), + filter.Enable(), + filter.Enable(), + filter.Enable(), + filter.Enable(), + filter.Enable() + }); + } +} diff --git a/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs b/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs index f99d9130bd..9dc47dd919 100644 --- a/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs +++ b/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs @@ -956,7 +956,7 @@ public abstract class AbpDbContext : DbContext, IAbpEfCoreDbContext, return expression; } - protected virtual bool UseDbFunction() + public virtual bool UseDbFunction() { return LazyServiceProvider != null && GlobalFilterOptions.Value.UseDbFunction && DbContextOptions.FindExtension() != null; } diff --git a/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/GlobalFilters/AbpCompiledQueryCacheKeyGenerator.cs b/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/GlobalFilters/AbpCompiledQueryCacheKeyGenerator.cs index e43ae1e04d..8ce26b7ce1 100644 --- a/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/GlobalFilters/AbpCompiledQueryCacheKeyGenerator.cs +++ b/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/GlobalFilters/AbpCompiledQueryCacheKeyGenerator.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; using System.Linq.Expressions; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Query; +using Microsoft.Extensions.DependencyInjection; namespace Volo.Abp.EntityFrameworkCore.GlobalFilters; @@ -23,7 +25,21 @@ public class AbpCompiledQueryCacheKeyGenerator : ICompiledQueryCacheKeyGenerator var cacheKey = InnerCompiledQueryCacheKeyGenerator.GenerateCacheKey(query, async); if (CurrentContext.Context is IAbpEfCoreDbFunctionContext abpEfCoreDbFunctionContext) { - return new AbpCompiledQueryCacheKey(cacheKey, abpEfCoreDbFunctionContext.GetCompiledQueryCacheKey()); + var abpCacheKey = abpEfCoreDbFunctionContext.GetCompiledQueryCacheKey(); + var cacheKeyProviders = abpEfCoreDbFunctionContext.LazyServiceProvider.GetService>(); + if (cacheKeyProviders != null) + { + foreach (var provider in cacheKeyProviders) + { + var key = provider.GetCompiledQueryCacheKey(); + if (!key.IsNullOrWhiteSpace()) + { + abpCacheKey += $":{key}"; + } + } + } + + return new AbpCompiledQueryCacheKey(cacheKey, abpCacheKey); } return cacheKey; diff --git a/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/GlobalFilters/IAbpEfCoreCompiledQueryCacheKeyProvider.cs b/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/GlobalFilters/IAbpEfCoreCompiledQueryCacheKeyProvider.cs new file mode 100644 index 0000000000..80d563fc15 --- /dev/null +++ b/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/GlobalFilters/IAbpEfCoreCompiledQueryCacheKeyProvider.cs @@ -0,0 +1,6 @@ +namespace Volo.Abp.EntityFrameworkCore.GlobalFilters; + +public interface IAbpEfCoreCompiledQueryCacheKeyProvider +{ + string? GetCompiledQueryCacheKey(); +} diff --git a/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/GlobalFilters/IAbpEfCoreDbFunctionContext.cs b/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/GlobalFilters/IAbpEfCoreDbFunctionContext.cs index 85096e9e0a..3948de48b5 100644 --- a/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/GlobalFilters/IAbpEfCoreDbFunctionContext.cs +++ b/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/GlobalFilters/IAbpEfCoreDbFunctionContext.cs @@ -12,5 +12,7 @@ public interface IAbpEfCoreDbFunctionContext IDataFilter DataFilter { get; } + bool UseDbFunction(); + string GetCompiledQueryCacheKey(); } diff --git a/framework/src/Volo.Abp.MongoDB/Volo/Abp/Domain/Repositories/MongoDB/MongoDbRepository.cs b/framework/src/Volo.Abp.MongoDB/Volo/Abp/Domain/Repositories/MongoDB/MongoDbRepository.cs index 4beb71d188..e6b014b6f4 100644 --- a/framework/src/Volo.Abp.MongoDB/Volo/Abp/Domain/Repositories/MongoDB/MongoDbRepository.cs +++ b/framework/src/Volo.Abp.MongoDB/Volo/Abp/Domain/Repositories/MongoDB/MongoDbRepository.cs @@ -98,7 +98,7 @@ public class MongoDbRepository public IMongoDbBulkOperationProvider? BulkOperationProvider => LazyServiceProvider.LazyGetService(); - public IMongoDbRepositoryFilterer RepositoryFilterer => LazyServiceProvider.LazyGetService>()!; + public IEnumerable> RepositoryFilterers => LazyServiceProvider.LazyGetService>>()!; public MongoDbRepository(IMongoDbContextProvider dbContextProvider) : base(AbpMongoDbConsts.ProviderName) @@ -774,7 +774,10 @@ public class MongoDbRepository { if (typeof(TOtherEntity) == typeof(TEntity)) { - return base.ApplyDataFilters((TQueryable)RepositoryFilterer.FilterQueryable(query.As>())); + foreach (var filterer in RepositoryFilterers) + { + query = (TQueryable) filterer.FilterQueryable(query.As>()); + } } return base.ApplyDataFilters(query); } @@ -786,7 +789,7 @@ public class MongoDbRepository where TMongoDbContext : IAbpMongoDbContext where TEntity : class, IEntity { - public IMongoDbRepositoryFilterer RepositoryFiltererWithKey => LazyServiceProvider.LazyGetService>()!; + public IEnumerable> RepositoryFiltererWithKeys => LazyServiceProvider.LazyGetService>>()!; public MongoDbRepository(IMongoDbContextProvider dbContextProvider) : base(dbContextProvider) @@ -844,19 +847,38 @@ public class MongoDbRepository { if (typeof(TOtherEntity) == typeof(TEntity)) { - return base.ApplyDataFilters((TQueryable)RepositoryFiltererWithKey.FilterQueryable(query.As>())); + foreach (var filterer in RepositoryFiltererWithKeys) + { + query = (TQueryable) filterer.FilterQueryable(query.As>()); + } } return base.ApplyDataFilters(query); } - protected async override Task> CreateEntityFilterAsync(TEntity entity, bool withConcurrencyStamp = false, string? concurrencyStamp = null) + protected override async Task> CreateEntityFilterAsync(TEntity entity, bool withConcurrencyStamp = false, string? concurrencyStamp = null) { - return await RepositoryFiltererWithKey.CreateEntityFilterAsync(entity, withConcurrencyStamp, concurrencyStamp); + FilterDefinition fieldDefinition = Builders.Filter.Empty; + foreach (var filterer in RepositoryFiltererWithKeys) + { + fieldDefinition = Builders.Filter.And( + fieldDefinition, + await filterer.CreateEntityFilterAsync(entity, withConcurrencyStamp, concurrencyStamp) + ); + } + return fieldDefinition; } - protected async override Task> CreateEntitiesFilterAsync(IEnumerable entities, bool withConcurrencyStamp = false) + protected override async Task> CreateEntitiesFilterAsync(IEnumerable entities, bool withConcurrencyStamp = false) { - return await RepositoryFiltererWithKey.CreateEntitiesFilterAsync(entities, withConcurrencyStamp); + FilterDefinition fieldDefinition = Builders.Filter.Empty; + foreach (var filterer in RepositoryFiltererWithKeys) + { + fieldDefinition = Builders.Filter.And( + fieldDefinition, + await filterer.CreateEntitiesFilterAsync(entities, withConcurrencyStamp) + ); + } + return fieldDefinition; } } diff --git a/framework/src/Volo.Abp.MongoDB/Volo/Abp/MongoDB/AbpMongoDbModule.cs b/framework/src/Volo.Abp.MongoDB/Volo/Abp/MongoDB/AbpMongoDbModule.cs index fb9d6eaea1..fc233e5695 100644 --- a/framework/src/Volo.Abp.MongoDB/Volo/Abp/MongoDB/AbpMongoDbModule.cs +++ b/framework/src/Volo.Abp.MongoDB/Volo/Abp/MongoDB/AbpMongoDbModule.cs @@ -36,15 +36,17 @@ public class AbpMongoDbModule : AbpModule typeof(UnitOfWorkMongoDbContextProvider<>) ); - context.Services.TryAddTransient( - typeof(IMongoDbRepositoryFilterer<>), - typeof(MongoDbRepositoryFilterer<>) - ); + context.Services.TryAddEnumerable( + ServiceDescriptor.Transient( + typeof(IMongoDbRepositoryFilterer<>), + typeof(MongoDbRepositoryFilterer<>) + )); - context.Services.TryAddTransient( - typeof(IMongoDbRepositoryFilterer<,>), - typeof(MongoDbRepositoryFilterer<,>) - ); + context.Services.TryAddEnumerable( + ServiceDescriptor.Transient( + typeof(IMongoDbRepositoryFilterer<,>), + typeof(MongoDbRepositoryFilterer<,>) + )); context.Services.AddTransient( typeof(IMongoDbContextEventOutbox<>), diff --git a/framework/src/Volo.Abp.MultiTenancy.Abstractions/Volo/Abp/MultiTenancy/AbpMultiTenancyOptions.cs b/framework/src/Volo.Abp.MultiTenancy.Abstractions/Volo/Abp/MultiTenancy/AbpMultiTenancyOptions.cs index fac41e5f20..247eba34ee 100644 --- a/framework/src/Volo.Abp.MultiTenancy.Abstractions/Volo/Abp/MultiTenancy/AbpMultiTenancyOptions.cs +++ b/framework/src/Volo.Abp.MultiTenancy.Abstractions/Volo/Abp/MultiTenancy/AbpMultiTenancyOptions.cs @@ -4,7 +4,7 @@ public class AbpMultiTenancyOptions { /// /// A central point to enable/disable multi-tenancy. - /// Default: false. + /// Default: false. /// public bool IsEnabled { get; set; } @@ -13,4 +13,10 @@ public class AbpMultiTenancyOptions /// Default: . /// public MultiTenancyDatabaseStyle DatabaseStyle { get; set; } = MultiTenancyDatabaseStyle.Hybrid; + + /// + /// User sharing strategy between tenants. + /// Default: . + /// + public TenantUserSharingStrategy UserSharingStrategy { get; set; } = TenantUserSharingStrategy.Isolated; } diff --git a/framework/src/Volo.Abp.MultiTenancy.Abstractions/Volo/Abp/MultiTenancy/TenantUserSharingStrategy.cs b/framework/src/Volo.Abp.MultiTenancy.Abstractions/Volo/Abp/MultiTenancy/TenantUserSharingStrategy.cs new file mode 100644 index 0000000000..0e413d7af2 --- /dev/null +++ b/framework/src/Volo.Abp.MultiTenancy.Abstractions/Volo/Abp/MultiTenancy/TenantUserSharingStrategy.cs @@ -0,0 +1,8 @@ +namespace Volo.Abp.MultiTenancy; + +public enum TenantUserSharingStrategy +{ + Isolated = 0, + + Shared = 1 +} diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpIdentityAspNetCoreModule.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpIdentityAspNetCoreModule.cs index 67b5d0bfa7..e06797ae54 100644 --- a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpIdentityAspNetCoreModule.cs +++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpIdentityAspNetCoreModule.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -47,6 +48,8 @@ public class AbpIdentityAspNetCoreModule : AbpModule public override void PostConfigureServices(ServiceConfigurationContext context) { + // Replace the default UserValidator with AbpIdentityUserValidator + context.Services.RemoveAll(x => x.ServiceType == typeof(IUserValidator) && x.ImplementationType == typeof(UserValidator)); context.Services.AddAbpOptions() .Configure((securityStampValidatorOptions, serviceProvider) => { diff --git a/modules/identity/src/Volo.Abp.Identity.Domain.Shared/Volo/Abp/Identity/IdentityUserPasswordChangedEto.cs b/modules/identity/src/Volo.Abp.Identity.Domain.Shared/Volo/Abp/Identity/IdentityUserPasswordChangedEto.cs new file mode 100644 index 0000000000..13e4def1d3 --- /dev/null +++ b/modules/identity/src/Volo.Abp.Identity.Domain.Shared/Volo/Abp/Identity/IdentityUserPasswordChangedEto.cs @@ -0,0 +1,14 @@ +using System; +using Volo.Abp.MultiTenancy; + +namespace Volo.Abp.Identity; + +[Serializable] +public class IdentityUserPasswordChangedEto : IMultiTenant +{ + public Guid Id { get; set; } + + public Guid? TenantId { get; set; } + + public string Email { get; set; } +} diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AbpIdentityUserValidator.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AbpIdentityUserValidator.cs index 84d1a09c25..5035a7ae64 100644 --- a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AbpIdentityUserValidator.cs +++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AbpIdentityUserValidator.cs @@ -1,66 +1,206 @@ +using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Localization; -using Volo.Abp.Identity.Localization; +using Microsoft.Extensions.Options; +using Volo.Abp.Data; +using Volo.Abp.DistributedLocking; +using Volo.Abp.MultiTenancy; namespace Volo.Abp.Identity { public class AbpIdentityUserValidator : IUserValidator { - protected IStringLocalizer Localizer { get; } + protected IdentityErrorDescriber ErrorDescriber { get; } + protected IOptions MultiTenancyOptions { get; } + protected IAbpDistributedLock DistributedLock { get; } + protected ICurrentTenant CurrentTenant { get; } + protected IDataFilter TenantFilter { get; } + protected IIdentityUserRepository UserRepository { get; } + protected IUserValidator DefaultUserValidator { get; } - public AbpIdentityUserValidator(IStringLocalizer localizer) + public AbpIdentityUserValidator( + IdentityErrorDescriber errorDescriber, + IOptions multiTenancyOptions, + IAbpDistributedLock distributedLock, + ICurrentTenant currentTenant, + IDataFilter tenantFilter, + IIdentityUserRepository userRepository) { - Localizer = localizer; + ErrorDescriber = errorDescriber; + MultiTenancyOptions = multiTenancyOptions; + DistributedLock = distributedLock; + CurrentTenant = currentTenant; + TenantFilter = tenantFilter; + UserRepository = userRepository; + DefaultUserValidator = new UserValidator(ErrorDescriber); } public virtual async Task ValidateAsync(UserManager manager, IdentityUser user) { - var describer = new IdentityErrorDescriber(); - Check.NotNull(manager, nameof(manager)); Check.NotNull(user, nameof(user)); + return MultiTenancyOptions.Value.UserSharingStrategy == TenantUserSharingStrategy.Isolated + ? await ValidateIsolatedUserAsync(manager, user) + : await ValidateSharedUserAsync(manager, user); + } + + protected virtual async Task ValidateIsolatedUserAsync(UserManager manager, IdentityUser user) + { var errors = new List(); + var defaultValidationResult = await DefaultUserValidator.ValidateAsync(manager, user); + if (!defaultValidationResult.Succeeded) + { + return defaultValidationResult; + } + var userName = await manager.GetUserNameAsync(user); if (userName == null) { - errors.Add(describer.InvalidUserName(null)); + errors.Add(ErrorDescriber.InvalidUserName(null)); } else { var owner = await manager.FindByEmailAsync(userName); if (owner != null && !string.Equals(await manager.GetUserIdAsync(owner), await manager.GetUserIdAsync(user))) { - errors.Add(new IdentityError - { - Code = "InvalidUserName", - Description = Localizer["Volo.Abp.Identity:InvalidUserName", userName] - }); + errors.Add(ErrorDescriber.InvalidUserName(userName)); } } var email = await manager.GetEmailAsync(user); if (email == null) { - errors.Add(describer.InvalidEmail(null)); + errors.Add(ErrorDescriber.InvalidEmail(null)); } else { var owner = await manager.FindByNameAsync(email); if (owner != null && !string.Equals(await manager.GetUserIdAsync(owner), await manager.GetUserIdAsync(user))) { - errors.Add(new IdentityError + errors.Add(ErrorDescriber.InvalidEmail(email)); + } + } + + return errors.Count > 0 ? IdentityResult.Failed(errors.ToArray()) : IdentityResult.Success; + } + + protected virtual async Task ValidateSharedUserAsync(UserManager manager, IdentityUser user) + { + var errors = new List(); + + var defaultValidationResult = await BuiltInValidateAsync(manager, user); + if (!defaultValidationResult.Succeeded) + { + return defaultValidationResult; + } + + await using var handle = await DistributedLock.TryAcquireAsync(nameof(AbpIdentityUserValidator), TimeSpan.FromMinutes(1)); + if (handle == null) + { + throw new AbpException("Could not acquire distributed lock for validating user uniqueness for shared user sharing strategy!"); + } + + using (CurrentTenant.Change(null)) + { + using (TenantFilter.Disable()) + { + var owner = await manager.FindByIdAsync(user.Id.ToString()); + var normalizedUserName = manager.NormalizeName(user.UserName); + var normalizedEmail = manager.NormalizeEmail(user.Email); + var users = await UserRepository.GetUsersByNormalizedUserNamesAsync([normalizedUserName!, normalizedEmail], true); + users.RemoveAll(x => x.Id == user.Id); + if (owner != null) { - Code = "InvalidEmail", - Description = Localizer["Volo.Abp.Identity:InvalidEmail", email] - }); + users.RemoveAll(x => x.NormalizedUserName == user.NormalizedUserName || x.NormalizedEmail == user.NormalizedEmail); + } + if (users.Any()) + { + var userNames = users.Select(u => u.UserName).ToList(); + errors.Add(userNames.Contains(user.UserName) ? ErrorDescriber.InvalidUserName(user.UserName!) : ErrorDescriber.InvalidEmail(user.Email!)); + } + + users = await UserRepository.GetUsersByNormalizedEmailsAsync([normalizedUserName!, normalizedEmail], true); + users.RemoveAll(x => x.Id == user.Id); + if (owner != null) + { + users.RemoveAll(x => x.NormalizedUserName == user.NormalizedUserName || x.NormalizedEmail == user.NormalizedEmail); + } + if (users.Any()) + { + var emails = users.Select(u => u.Email).ToList(); + errors.Add(emails.Contains(user.Email) ? ErrorDescriber.InvalidEmail(user.Email!) : ErrorDescriber.InvalidUserName(user.UserName!)); + } } } return errors.Count > 0 ? IdentityResult.Failed(errors.ToArray()) : IdentityResult.Success; } + + public virtual async Task BuiltInValidateAsync(UserManager manager, IdentityUser user) + { + var errors = await ValidateUserName(manager, user); + if (manager.Options.User.RequireUniqueEmail) + { + errors.AddRange(await ValidateEmail(manager, user)); + } + return errors?.Count > 0 ? IdentityResult.Failed(errors.ToArray()) : IdentityResult.Success; + } + + private async Task> ValidateUserName(UserManager manager, IdentityUser user) + { + var errors = new List(); + var userName = await manager.GetUserNameAsync(user); + if (string.IsNullOrWhiteSpace(userName)) + { + errors.Add(ErrorDescriber.InvalidUserName(userName)); + } + else if (!string.IsNullOrEmpty(manager.Options.User.AllowedUserNameCharacters) && + userName.Any(c => !manager.Options.User.AllowedUserNameCharacters.Contains(c))) + { + errors.Add(ErrorDescriber.InvalidUserName(userName)); + } + else + { + var owner = await manager.FindByNameAsync(userName); + if (owner != null && + !string.Equals(await manager.GetUserIdAsync(owner), await manager.GetUserIdAsync(user)) && + owner.TenantId == user.TenantId) + { + errors.Add(ErrorDescriber.DuplicateUserName(userName)); + } + } + + return errors; + } + + // make sure email is not empty, valid, and unique + private async Task> ValidateEmail(UserManager manager, IdentityUser user) + { + var errors = new List(); + var email = await manager.GetEmailAsync(user); + if (string.IsNullOrWhiteSpace(email)) + { + errors.Add(ErrorDescriber.InvalidEmail(email)); + return errors; + } + if (!new EmailAddressAttribute().IsValid(email)) + { + errors.Add(ErrorDescriber.InvalidEmail(email)); + return errors; + } + var owner = await manager.FindByEmailAsync(email); + if (owner != null && + !string.Equals(await manager.GetUserIdAsync(owner), await manager.GetUserIdAsync(user)) && + owner.TenantId == user.TenantId) + { + errors.Add(ErrorDescriber.DuplicateEmail(email)); + } + return errors; + } } } diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IIdentityUserRepository.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IIdentityUserRepository.cs index 33ec2eab95..d92717c44c 100644 --- a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IIdentityUserRepository.cs +++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IIdentityUserRepository.cs @@ -165,4 +165,42 @@ public interface IIdentityUserRepository : IBasicRepository byte[] credentialId, bool includeDetails = true, CancellationToken cancellationToken = default); + + Task> GetUsersByNormalizedUserNameAsync( + [NotNull] string normalizedUserName, + bool includeDetails = false, + CancellationToken cancellationToken = default + ); + + Task> GetUsersByNormalizedUserNamesAsync( + [NotNull] string[] normalizedUserNames, + bool includeDetails = false, + CancellationToken cancellationToken = default + ); + + Task> GetUsersByNormalizedEmailAsync( + [NotNull] string normalizedEmail, + bool includeDetails = false, + CancellationToken cancellationToken = default + ); + + Task> GetUsersByNormalizedEmailsAsync( + [NotNull] string[] normalizedEmails, + bool includeDetails = false, + CancellationToken cancellationToken = default + ); + + Task FindByNormalizedUserNameAsync( + Guid? tenantId, + [NotNull] string normalizedUserName, + bool includeDetails = true, + CancellationToken cancellationToken = default + ); + + Task FindByNormalizedEmailAsync( + Guid? tenantId, + [NotNull] string normalizedEmail, + bool includeDetails = true, + CancellationToken cancellationToken = default + ); } diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUser.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUser.cs index 3f4a91117c..214bce39fc 100644 --- a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUser.cs +++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUser.cs @@ -132,6 +132,11 @@ public class IdentityUser : FullAuditedAggregateRoot, IUser, IHasEntityVer /// public virtual DateTimeOffset? LastSignInTime { get; protected set; } + /// + /// Gets or sets a flag indicating whether this user is leaved from tenant. + /// + public virtual bool Leaved { get; protected set; } + //TODO: Can we make collections readonly collection, which will provide encapsulation. But... can work for all ORMs? /// @@ -435,6 +440,43 @@ public class IdentityUser : FullAuditedAggregateRoot, IUser, IHasEntityVer Passkeys.RemoveAll(x => x.CredentialId.SequenceEqual(credentialId)); } + /// + /// This method set the UserName and normalizedUserName without any validation. + /// Do not use it directly. Use UserManager to change the user name. + /// + public virtual void SetUserNameWithoutValidation(string userName, string normalizedUserName) + { + UserName = userName; + NormalizedUserName = normalizedUserName; + } + + /// + /// This method set the Email and NormalizedEmail without any validation. + /// Do not use it directly. Use UserManager to change the email. + /// + /// + /// + public virtual void SetEmailWithoutValidation(string email, string normalizedEmail) + { + Email = email; + NormalizedEmail = normalizedEmail; + } + + /// + /// This method set the PasswordHash without any validation. + /// Do not use it directly. Use UserManager to change the password. + /// + /// + public virtual void SetPasswordHashWithoutValidation(string passwordHash) + { + PasswordHash = passwordHash; + } + + public virtual void SetLeaved(bool leaved) + { + Leaved = leaved; + } + public override string ToString() { return $"{base.ToString()}, UserName = {UserName}"; diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs index a4fc897bf4..6a1f8ea848 100644 --- a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs +++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs @@ -116,7 +116,7 @@ public class IdentityUserManager : UserManager, IDomainService /// A representing whether validation was successful. public virtual async Task CallValidateUserAsync(IdentityUser user) { - return await base.ValidateUserAsync(user); + return await ValidateUserAsync(user); } /// @@ -129,7 +129,20 @@ public class IdentityUserManager : UserManager, IDomainService /// A representing whether validation was successful. public virtual async Task CallValidatePasswordAsync(IdentityUser user, string password) { - return await base.ValidatePasswordAsync(user, password); + return await ValidatePasswordAsync(user, password); + } + + /// + /// This is to call the protection method UpdatePasswordHash + /// Updates a user's password hash. + /// + /// The user. + /// The new password. + /// Whether to validate the password. + /// Whether the password has was successfully updated. + public virtual async Task CallUpdatePasswordHash(IdentityUser user, string newPassword, bool validatePassword) + { + return await UpdatePasswordHash(user, newPassword, validatePassword); } public virtual async Task GetByIdAsync(Guid id) @@ -396,6 +409,22 @@ public class IdentityUserManager : UserManager, IDomainService return result; } + public override async Task ChangePasswordAsync(IdentityUser user, string currentPassword, string newPassword) + { + var result = await base.ChangePasswordAsync(user, currentPassword, newPassword); + + result.CheckErrors(); + + await DistributedEventBus.PublishAsync(new IdentityUserPasswordChangedEto + { + Id = user.Id, + TenantId = user.TenantId, + Email = user.Email, + }); + + return result; + } + public virtual async Task UpdateRoleAsync(Guid sourceRoleId, Guid? targetRoleId) { var sourceRole = await RoleRepository.GetAsync(sourceRoleId, cancellationToken: CancellationToken); diff --git a/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/EfCoreIdentityUserRepository.cs b/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/EfCoreIdentityUserRepository.cs index 68c05738db..459a73593c 100644 --- a/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/EfCoreIdentityUserRepository.cs +++ b/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/EfCoreIdentityUserRepository.cs @@ -455,6 +455,66 @@ public class EfCoreIdentityUserRepository : EfCoreRepository> GetUsersByNormalizedUserNameAsync(string normalizedUserName, bool includeDetails = false, CancellationToken cancellationToken = default) + { + return await (await GetDbSetAsync()) + .IncludeDetails(includeDetails) + .OrderBy(x => x.Id) + .Where(u => u.NormalizedUserName == normalizedUserName) + .ToListAsync(GetCancellationToken(cancellationToken)); + } + + public virtual async Task> GetUsersByNormalizedUserNamesAsync(string[] normalizedUserNames, bool includeDetails = false, CancellationToken cancellationToken = default) + { + return await (await GetDbSetAsync()) + .IncludeDetails(includeDetails) + .OrderBy(x => x.Id) + .Where(u => normalizedUserNames.Contains(u.NormalizedUserName)) + .Distinct() + .ToListAsync(GetCancellationToken(cancellationToken)); + } + + public virtual async Task> GetUsersByNormalizedEmailAsync(string normalizedEmail, bool includeDetails = false, CancellationToken cancellationToken = default) + { + return await (await GetDbSetAsync()) + .IncludeDetails(includeDetails) + .OrderBy(x => x.Id) + .Where(u => u.NormalizedEmail == normalizedEmail) + .ToListAsync(GetCancellationToken(cancellationToken)); + } + + public virtual async Task> GetUsersByNormalizedEmailsAsync(string[] normalizedEmails, bool includeDetails = false, CancellationToken cancellationToken = default) + { + return await (await GetDbSetAsync()) + .IncludeDetails(includeDetails) + .OrderBy(x => x.Id) + .Where(u => normalizedEmails.Contains(u.NormalizedEmail)) + .Distinct() + .ToListAsync(GetCancellationToken(cancellationToken)); + } + + public virtual async Task FindByNormalizedUserNameAsync(Guid? tenantId, string normalizedUserName, bool includeDetails = true, CancellationToken cancellationToken = default) + { + return await (await GetDbSetAsync()) + .IncludeDetails(includeDetails) + .OrderBy(x => x.Id) + .FirstOrDefaultAsync( + u => u.TenantId == tenantId && u.NormalizedUserName == normalizedUserName, + GetCancellationToken(cancellationToken) + ); + } + + public virtual async Task FindByNormalizedEmailAsync(Guid? tenantId, string normalizedEmail, bool includeDetails = true, CancellationToken cancellationToken = default) + { + return await (await GetDbSetAsync()) + .IncludeDetails(includeDetails) + .OrderBy(x => x.Id) + .FirstOrDefaultAsync( + u => u.TenantId == tenantId && u.NormalizedEmail == normalizedEmail, + GetCancellationToken(cancellationToken) + ); + } + protected virtual async Task> GetFilteredQueryableAsync( string filter = null, Guid? roleId = null, diff --git a/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/IdentityDbContextModelBuilderExtensions.cs b/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/IdentityDbContextModelBuilderExtensions.cs index 2b302e4cf8..429863c5ae 100644 --- a/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/IdentityDbContextModelBuilderExtensions.cs +++ b/modules/identity/src/Volo.Abp.Identity.EntityFrameworkCore/Volo/Abp/Identity/EntityFrameworkCore/IdentityDbContextModelBuilderExtensions.cs @@ -33,7 +33,8 @@ public static class IdentityDbContextModelBuilderExtensions .HasColumnName(nameof(IdentityUser.TwoFactorEnabled)); b.Property(u => u.LockoutEnabled).HasDefaultValue(false) .HasColumnName(nameof(IdentityUser.LockoutEnabled)); - + b.Property(u => u.Leaved).HasDefaultValue(false) + .HasColumnName(nameof(IdentityUser.Leaved)); b.Property(u => u.IsExternal).IsRequired().HasDefaultValue(false) .HasColumnName(nameof(IdentityUser.IsExternal)); diff --git a/modules/identity/src/Volo.Abp.Identity.MongoDB/Volo/Abp/Identity/MongoDB/MongoIdentityUserRepository.cs b/modules/identity/src/Volo.Abp.Identity.MongoDB/Volo/Abp/Identity/MongoDB/MongoIdentityUserRepository.cs index 2a8654cd3b..718d461b87 100644 --- a/modules/identity/src/Volo.Abp.Identity.MongoDB/Volo/Abp/Identity/MongoDB/MongoIdentityUserRepository.cs +++ b/modules/identity/src/Volo.Abp.Identity.MongoDB/Volo/Abp/Identity/MongoDB/MongoIdentityUserRepository.cs @@ -451,6 +451,59 @@ public class MongoIdentityUserRepository : MongoDbRepository> GetUsersByNormalizedUserNameAsync(string normalizedUserName, bool includeDetails = false, CancellationToken cancellationToken = default) + { + return await (await GetQueryableAsync(cancellationToken)) + .OrderBy(x => x.Id) + .Where(u => u.NormalizedUserName == normalizedUserName) + .ToListAsync(cancellationToken: cancellationToken); + } + + public virtual async Task> GetUsersByNormalizedUserNamesAsync(string[] normalizedUserNames, bool includeDetails = false, CancellationToken cancellationToken = default) + { + return await (await GetQueryableAsync(cancellationToken)) + .OrderBy(x => x.Id) + .Where(u => normalizedUserNames.Contains(u.NormalizedUserName)) + .Distinct() + .ToListAsync(cancellationToken: cancellationToken); + } + + public virtual async Task> GetUsersByNormalizedEmailAsync(string normalizedEmail, bool includeDetails = false, CancellationToken cancellationToken = default) + { + return await (await GetQueryableAsync(cancellationToken)) + .OrderBy(x => x.Id) + .Where(u => u.NormalizedEmail == normalizedEmail) + .ToListAsync(cancellationToken: cancellationToken); + } + + public virtual async Task> GetUsersByNormalizedEmailsAsync(string[] normalizedEmails, bool includeDetails = false, CancellationToken cancellationToken = default) + { + return await (await GetQueryableAsync(cancellationToken)) + .OrderBy(x => x.Id) + .Where(u => normalizedEmails.Contains(u.NormalizedEmail)) + .Distinct() + .ToListAsync(cancellationToken: cancellationToken); + } + + public virtual async Task FindByNormalizedUserNameAsync(Guid? tenantId, string normalizedUserName, bool includeDetails = true, CancellationToken cancellationToken = default) + { + return await (await GetQueryableAsync(cancellationToken)) + .OrderBy(x => x.Id) + .FirstOrDefaultAsync( + u => u.TenantId == tenantId && u.NormalizedUserName == normalizedUserName, + GetCancellationToken(cancellationToken) + ); + } + + public virtual async Task FindByNormalizedEmailAsync(Guid? tenantId, string normalizedEmail, bool includeDetails = true, CancellationToken cancellationToken = default) + { + return await (await GetQueryableAsync(cancellationToken)) + .OrderBy(x => x.Id).FirstOrDefaultAsync( + u => u.TenantId == tenantId && u.NormalizedEmail == normalizedEmail, + GetCancellationToken(cancellationToken) + ); + } + protected virtual async Task> GetFilteredQueryableAsync( string filter = null, Guid? roleId = null, diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpIdentityUserValidator_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpIdentityUserValidator_Tests.cs index 6b656fbec4..38f0aaa9a3 100644 --- a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpIdentityUserValidator_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpIdentityUserValidator_Tests.cs @@ -1,9 +1,12 @@ using System; using System.Linq; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Localization; using Shouldly; using Volo.Abp.Identity.Localization; +using Volo.Abp.MultiTenancy; using Xunit; namespace Volo.Abp.Identity.AspNetCore; @@ -60,3 +63,15 @@ public class AbpIdentityUserValidator_Tests : AbpIdentityAspNetCoreTestBase identityResult.Errors.First().Description.ShouldBe(Localizer["Volo.Abp.Identity:InvalidEmail", "user1@volosoft.com"]); } } + +public class SharedTenantUserSharingStrategy_AbpIdentityUserValidator_Tests : AbpIdentityUserValidator_Tests +{ + protected override void ConfigureServices(HostBuilderContext context, IServiceCollection services) + { + services.Configure(options => + { + options.IsEnabled = true; + options.UserSharingStrategy = TenantUserSharingStrategy.Shared; + }); + } +} diff --git a/modules/users/src/Volo.Abp.Users.Abstractions/Volo/Abp/Users/InviteUserToTenantRequestedEto.cs b/modules/users/src/Volo.Abp.Users.Abstractions/Volo/Abp/Users/InviteUserToTenantRequestedEto.cs new file mode 100644 index 0000000000..8894f86a89 --- /dev/null +++ b/modules/users/src/Volo.Abp.Users.Abstractions/Volo/Abp/Users/InviteUserToTenantRequestedEto.cs @@ -0,0 +1,14 @@ +using System; +using Volo.Abp.EventBus; +using Volo.Abp.MultiTenancy; + +namespace Volo.Abp.Users; + +[Serializable] +[EventName("Volo.Abp.Users.InviteUserToTenantRequested")] +public class InviteUserToTenantRequestedEto : IMultiTenant +{ + public Guid? TenantId { get; set; } + + public string Email { get; set; } +}