diff --git a/docs/en/images/exist-user-accept.png b/docs/en/images/exist-user-accept.png new file mode 100644 index 0000000000..23f35c0904 Binary files /dev/null and b/docs/en/images/exist-user-accept.png differ diff --git a/docs/en/images/invite-admin-user-to-join-tenant-modal.png b/docs/en/images/invite-admin-user-to-join-tenant-modal.png new file mode 100644 index 0000000000..8fa9d2fee9 Binary files /dev/null and b/docs/en/images/invite-admin-user-to-join-tenant-modal.png differ diff --git a/docs/en/images/invite-admin-user-to-join-tenant.png b/docs/en/images/invite-admin-user-to-join-tenant.png new file mode 100644 index 0000000000..edfb5bedb0 Binary files /dev/null and b/docs/en/images/invite-admin-user-to-join-tenant.png differ diff --git a/docs/en/images/invite-user.png b/docs/en/images/invite-user.png new file mode 100644 index 0000000000..67a3f04073 Binary files /dev/null and b/docs/en/images/invite-user.png differ diff --git a/docs/en/images/manage-invitations.png b/docs/en/images/manage-invitations.png new file mode 100644 index 0000000000..969d09cc53 Binary files /dev/null and b/docs/en/images/manage-invitations.png differ diff --git a/docs/en/images/new-user-accept.png b/docs/en/images/new-user-accept.png new file mode 100644 index 0000000000..ffc887f1ed Binary files /dev/null and b/docs/en/images/new-user-accept.png differ diff --git a/docs/en/images/new-user-join-strategy-create-tenant-success.png b/docs/en/images/new-user-join-strategy-create-tenant-success.png new file mode 100644 index 0000000000..2d2969b631 Binary files /dev/null and b/docs/en/images/new-user-join-strategy-create-tenant-success.png differ diff --git a/docs/en/images/new-user-join-strategy-create-tenant.png b/docs/en/images/new-user-join-strategy-create-tenant.png new file mode 100644 index 0000000000..7d4a64c7c0 Binary files /dev/null and b/docs/en/images/new-user-join-strategy-create-tenant.png differ diff --git a/docs/en/images/new-user-join-strategy-inform.png b/docs/en/images/new-user-join-strategy-inform.png new file mode 100644 index 0000000000..a6a62e1c96 Binary files /dev/null and b/docs/en/images/new-user-join-strategy-inform.png differ diff --git a/docs/en/images/switch-tenant.png b/docs/en/images/switch-tenant.png new file mode 100644 index 0000000000..6f19de1da7 Binary files /dev/null and b/docs/en/images/switch-tenant.png differ diff --git a/docs/en/images/tenant-selection.png b/docs/en/images/tenant-selection.png new file mode 100644 index 0000000000..e40bf6aaeb Binary files /dev/null and b/docs/en/images/tenant-selection.png differ diff --git a/docs/en/images/user-accepted.png b/docs/en/images/user-accepted.png new file mode 100644 index 0000000000..5273333f6f Binary files /dev/null and b/docs/en/images/user-accepted.png differ diff --git a/docs/en/modules/account-pro.md b/docs/en/modules/account-pro.md index 7944c7448a..a79c3d4992 100644 --- a/docs/en/modules/account-pro.md +++ b/docs/en/modules/account-pro.md @@ -425,3 +425,5 @@ This module doesn't define any additional distributed event. See the [standard d * [Session Management](./account/session-management.md) * [Idle Session Timeout](./account/idle-session-timeout.md) * [Web Authentication API (WebAuthn) passkeys](./account/passkey.md) +* [Shared user accounts](./account/shared-user-accounts.md) +``` \ No newline at end of file diff --git a/docs/en/modules/account/shared-user-accounts.md b/docs/en/modules/account/shared-user-accounts.md new file mode 100644 index 0000000000..4df18e2fdf --- /dev/null +++ b/docs/en/modules/account/shared-user-accounts.md @@ -0,0 +1,151 @@ +```json +//[doc-seo] +{ + "Description": "Learn how Shared User Accounts work in ABP (UserSharingStrategy): login flow with tenant selection, switching tenants, inviting users, and the Pending Tenant registration flow." +} +``` + +# Shared User Accounts + +This document explains **Shared User Accounts**: a single user account can belong to multiple tenants, and the user can choose/switch the active tenant when signing in. + +> This is a **commercial** feature. It is mainly provided by Account.Pro and Identity.Pro (and related SaaS UI). + +## Introduction + +In a typical multi-tenant setup with the **Isolated** user strategy, a user belongs to exactly one tenant (or the Host), and uniqueness rules (username/email) are usually scoped per tenant. + +If you want a `one account, multiple tenants` experience (for example, inviting the same email address into multiple tenants), you should enable the **Shared** user strategy. + +## Enabling Shared User Accounts + +Enable shared accounts by configuring `AbpMultiTenancyOptions.UserSharingStrategy`: + +```csharp +Configure(options => +{ + options.IsEnabled = true; + options.UserSharingStrategy = TenantUserSharingStrategy.Shared; +}); +``` + +### Constraints and Behavior + +When you use Shared User Accounts: + +- Username/email uniqueness becomes **global** (Host + all tenants). A username/email can exist only once, but that user can be invited into multiple tenants. +- Some security/user management settings (2FA, lockout, password policies, recaptcha, etc.) are managed at the **Host** level. + +If you are migrating from an isolated strategy, ABP will validate the existing data when you switch to Shared. If there are conflicts (e.g., the same email registered as separate users in different tenants), you must resolve them before enabling the shared strategy. See the [Migration Guide](#migration-guide) section below. + +## Tenant Selection During Login + +If a user account belongs to multiple tenants, the login flow prompts the user to select the tenant to sign in to: + +![Tenant Selection](../../images/tenant-selection.png) + +## Switching Tenants + +After signing in, the user can switch between the tenants they have joined using the tenant switcher in the user menu: + +![Tenant Switching](../../images/switch-tenant.png) + +## Leaving a Tenant + +Users can leave a tenant. After leaving, the user is no longer a member of that tenant, and the tenant can invite the user again later. + +> When a user leaves and later re-joins the same tenant, the `UserId` does not change and tenant-related data (roles, permissions, etc.) is preserved. + +## Inviting Users to a Tenant + +Tenant administrators can invite existing or not-yet-registered users to join a tenant. The invited user receives an email; clicking the link completes the join process. If the user doesn't have an account yet, they can register and join through the same flow. + +While inviting, you can also assign roles so the user gets the relevant permissions automatically after joining. + +> The invitation feature is also available in the Isolated strategy, but invited users can join only a single tenant. + +![Invite User](../../images/invite-user.png) + +## Managing Invitations + +From the invitation modal, you can view and manage sent invitations, including resending an invitation email and revoking individual or all invitations. + +![Manage Invitations](../../images/manage-invitations.png) + +## Accepting an Invitation + +If the invited person already has an account, clicking the email link shows a confirmation screen to join the tenant: + +![Accept Invitation](../../images/exist-user-accept.png) + +If the invited person doesn't have an account yet, clicking the email link takes them to registration and then joins them to the tenant: + +![Accept Invitation New User](../../images/new-user-accept.png) + +After accepting the invitation, the user can sign in and switch to that tenant. + +![Accepted Invitation](../../images/user-accepted.png) + +## Inviting an Admin After Tenant Creation + +With Shared User Accounts, you typically don't create an `admin` user during tenant creation. Instead, create the tenant first, then invite an existing user (or a new user) and grant the required roles. + +![Invite tenant admin user](../../images/invite-admin-user-to-join-tenant.png) + +![Invite tenant admin user](../../images/invite-admin-user-to-join-tenant-modal.png) + +> In the Isolated strategy, tenant creation commonly seeds an `admin` user automatically. With Shared User Accounts, you usually use invitations instead. + +### Registration Strategy for New Users + +When a user registers a new account, the user is not a member of any tenant by default (and is not a Host user). You can configure `AbpIdentityPendingTenantUserOptions.Strategy` to decide what happens next. + +Available strategies: + +- **CreateTenant**: Automatically creates a tenant for the new user and adds the user to that tenant. +- **Redirect**: Redirects the user to a URL where you can implement custom logic (commonly: a tenant selection/join experience). +- **Inform** (default): Shows an informational message telling the user to contact an administrator to join a tenant. + +> In this state, the user can't proceed into a tenant context until they follow the configured strategy. + +### CreateTenant Strategy + +```csharp +Configure(options => +{ + options.Strategy = AbpIdentityPendingTenantUserStrategy.CreateTenant; +}); +``` + +![new-user--join-strategy-create-tenant](../../images/new-user-join-strategy-create-tenant.png) + +![new-user--join-strategy-create-tenant-success](../../images/new-user-join-strategy-create-tenant-success.png) + +### Redirect Strategy + +```csharp +Configure(options => +{ + options.Strategy = AbpIdentityPendingTenantUserStrategy.Redirect; + options.RedirectUrl = "/your-custom-logic-url"; +}); +``` + +### Inform Strategy + +```csharp +Configure(options => +{ + options.Strategy = AbpIdentityPendingTenantUserStrategy.Inform; +}); +``` + +![new-user--join-strategy-inform](../../images/new-user-join-strategy-inform.png) + +## Migration Guide + +If you plan to migrate an existing multi-tenant application from an isolated strategy to Shared User Accounts, keep the following in mind: + +1. **Uniqueness check**: Before enabling Shared, ensure all existing usernames and emails are unique globally. ABP performs this check when you switch the strategy and reports conflicts. +2. **Tenants with separate databases**: If some tenants use separate databases, you must ensure the Host database contains matching user records in the `AbpUsers` table (and, if you use social login / passkeys, also sync `AbpUserLogins` and `AbpUserPasskeys`) so the Host-side records match the tenant-side data. After that, the framework can create/manage the user-to-tenant associations. + diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo.Abp.AspNetCore.Mvc.Contracts.csproj b/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo.Abp.AspNetCore.Mvc.Contracts.csproj index c98b9b39d1..887ee1fa08 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo.Abp.AspNetCore.Mvc.Contracts.csproj +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo.Abp.AspNetCore.Mvc.Contracts.csproj @@ -18,6 +18,7 @@ + diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcContractsModule.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcContractsModule.cs index 278fe6eb21..2132764b75 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcContractsModule.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcContractsModule.cs @@ -1,11 +1,13 @@ using Volo.Abp.Application; using Volo.Abp.Modularity; +using Volo.Abp.MultiTenancy; namespace Volo.Abp.AspNetCore.Mvc; [DependsOn( - typeof(AbpDddApplicationContractsModule) - )] + typeof(AbpDddApplicationContractsModule), + typeof(AbpMultiTenancyAbstractionsModule) +)] public class AbpAspNetCoreMvcContractsModule : AbpModule { diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/MultiTenancy/MultiTenancyInfoDto.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/MultiTenancy/MultiTenancyInfoDto.cs index fcbb911ad7..27085a645d 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/MultiTenancy/MultiTenancyInfoDto.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/MultiTenancy/MultiTenancyInfoDto.cs @@ -1,6 +1,10 @@ -namespace Volo.Abp.AspNetCore.Mvc.MultiTenancy; +using Volo.Abp.MultiTenancy; + +namespace Volo.Abp.AspNetCore.Mvc.MultiTenancy; public class MultiTenancyInfoDto { public bool IsEnabled { get; set; } + + public TenantUserSharingStrategy UserSharingStrategy { get; set; } } diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/AbpApplicationConfigurationAppService.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/AbpApplicationConfigurationAppService.cs index 73edea9fdf..b1ae63c39b 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/AbpApplicationConfigurationAppService.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/AbpApplicationConfigurationAppService.cs @@ -135,7 +135,8 @@ public class AbpApplicationConfigurationAppService : ApplicationService, IAbpApp { return new MultiTenancyInfoDto { - IsEnabled = _multiTenancyOptions.IsEnabled + IsEnabled = _multiTenancyOptions.IsEnabled, + UserSharingStrategy = _multiTenancyOptions.UserSharingStrategy }; } 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 07ea443fdf..074f2fd000 100644 --- a/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs +++ b/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs @@ -994,7 +994,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/CreateTenantEto.cs b/framework/src/Volo.Abp.MultiTenancy.Abstractions/Volo/Abp/MultiTenancy/CreateTenantEto.cs new file mode 100644 index 0000000000..c5d7c255df --- /dev/null +++ b/framework/src/Volo.Abp.MultiTenancy.Abstractions/Volo/Abp/MultiTenancy/CreateTenantEto.cs @@ -0,0 +1,14 @@ +using System; +using Volo.Abp.Domain.Entities.Events.Distributed; +using Volo.Abp.EventBus; + +namespace Volo.Abp.MultiTenancy; + +[Serializable] +[EventName("abp.multi_tenancy.create.tenant")] +public class CreateTenantEto : EtoBase +{ + public string Name { get; set; } = default!; + + public string AdminEmailAddress { get; set; } = default!; +} 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.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSignInManager.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSignInManager.cs index 09f3e4bf24..ef375c8b6f 100644 --- a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSignInManager.cs +++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSignInManager.cs @@ -46,6 +46,7 @@ public class AbpSignInManager : SignInManager bool isPersistent, bool lockoutOnFailure) { + IdentityUser user; foreach (var externalLoginProviderInfo in AbpOptions.ExternalLoginProviders.Values) { var externalLoginProvider = (IExternalLoginProvider)Context.RequestServices @@ -53,7 +54,7 @@ public class AbpSignInManager : SignInManager if (await externalLoginProvider.TryAuthenticateAsync(userName, password)) { - var user = await UserManager.FindByNameAsync(userName); + user = await FindByNameAsync(userName); if (user == null) { if (externalLoginProvider is IExternalLoginProviderWithPassword externalLoginProviderWithPassword) @@ -81,7 +82,44 @@ public class AbpSignInManager : SignInManager } } - return await base.PasswordSignInAsync(userName, password, isPersistent, lockoutOnFailure); + user = await FindByNameAsync(userName); + if (user == null) + { + return SignInResult.Failed; + } + + return await PasswordSignInAsync(user, password, isPersistent, lockoutOnFailure); + } + + public override async Task ExternalLoginSignInAsync(string loginProvider, string providerKey, bool isPersistent, bool bypassTwoFactor) + { + var user = await FindByLoginAsync(loginProvider, providerKey); + if (user == null) + { + return SignInResult.Failed; + } + + var error = await PreSignInCheck(user); + if (error != null) + { + return error; + } + return await SignInOrTwoFactorAsync(user, isPersistent, loginProvider, bypassTwoFactor); + } + + public virtual async Task FindByEmaiAsync(string email) + { + return await _identityUserManager.FindSharedUserByEmailAsync(email); + } + + public virtual async Task FindByNameAsync(string userName) + { + return await _identityUserManager.FindSharedUserByNameAsync(userName); + } + + public virtual async Task FindByLoginAsync(string loginProvider, string providerKey) + { + return await _identityUserManager.FindSharedUserByLoginAsync(loginProvider, providerKey); } /// 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 05c733c0c0..cfceb47b3f 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,18 +1,35 @@ +using System; using System.Collections.Generic; +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 IOptions MultiTenancyOptions { get; } + protected IAbpDistributedLock DistributedLock { get; } + protected ICurrentTenant CurrentTenant { get; } + protected IDataFilter TenantFilter { get; } + protected IIdentityUserRepository UserRepository { get; } - public AbpIdentityUserValidator(IStringLocalizer localizer) + public AbpIdentityUserValidator( + IOptions multiTenancyOptions, + IAbpDistributedLock distributedLock, + ICurrentTenant currentTenant, + IDataFilter tenantFilter, + IIdentityUserRepository userRepository) { - Localizer = localizer; + MultiTenancyOptions = multiTenancyOptions; + DistributedLock = distributedLock; + CurrentTenant = currentTenant; + TenantFilter = tenantFilter; + UserRepository = userRepository; } public virtual async Task ValidateAsync(UserManager manager, IdentityUser user) @@ -20,8 +37,22 @@ namespace Volo.Abp.Identity Check.NotNull(manager, nameof(manager)); Check.NotNull(user, nameof(user)); + var defaultUserValidator = new UserValidator(manager.ErrorDescriber); + return MultiTenancyOptions.Value.UserSharingStrategy == TenantUserSharingStrategy.Isolated + ? await ValidateIsolatedUserAsync(manager, user, defaultUserValidator) + : await ValidateSharedUserAsync(manager, user, defaultUserValidator); + } + + protected virtual async Task ValidateIsolatedUserAsync(UserManager manager, IdentityUser user, UserValidator defaultValidator) + { var errors = new List(); + var defaultValidationResult = await defaultValidator.ValidateAsync(manager, user); + if (!defaultValidationResult.Succeeded) + { + return defaultValidationResult; + } + var userName = await manager.GetUserNameAsync(user); if (userName == null) { @@ -52,5 +83,84 @@ namespace Volo.Abp.Identity return errors.Count > 0 ? IdentityResult.Failed(errors.ToArray()) : IdentityResult.Success; } + + protected virtual async Task ValidateSharedUserAsync(UserManager manager, IdentityUser user, UserValidator defaultValidator) + { + var errors = new List(); + + using (CurrentTenant.Change(user.TenantId)) + { + var defaultValidationResult = await defaultValidator.ValidateAsync(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()) + { + IdentityUser owner; + using (CurrentTenant.Change(user.TenantId)) + { + 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)).Where(x => x.Id != user.Id).ToList(); + var usersByUserName = users.Where(x => x.NormalizedUserName == normalizedUserName).ToList(); + if (owner != null) + { + usersByUserName.RemoveAll(x => x.NormalizedUserName == user.NormalizedUserName); + } + if (usersByUserName.Any()) + { + errors.Add(manager.ErrorDescriber.DuplicateUserName(user.UserName!)); + } + + var usersByEmail = users.Where(x => x.NormalizedUserName == normalizedEmail).ToList(); + if (owner != null) + { + usersByEmail.RemoveAll(x => x.NormalizedEmail == user.NormalizedEmail); + } + if (usersByEmail.Any()) + { + errors.Add(manager.ErrorDescriber.InvalidEmail(user.Email!)); + } + + users = await UserRepository.GetUsersByNormalizedEmailsAsync([normalizedEmail!, normalizedUserName!], true); + usersByEmail = users.Where(x => x.NormalizedEmail == normalizedEmail).ToList(); + if (owner != null) + { + usersByEmail.RemoveAll(x => x.NormalizedEmail == user.NormalizedEmail); + } + if (usersByEmail.Any()) + { + errors.Add(manager.ErrorDescriber.DuplicateEmail(user.Email!)); + } + + usersByUserName = users.Where(x => x.NormalizedEmail == normalizedUserName).ToList(); + if (owner != null) + { + usersByUserName.RemoveAll(x => x.NormalizedUserName == user.NormalizedUserName); + } + if (usersByUserName.Any()) + { + errors.Add(manager.ErrorDescriber.InvalidUserName(user.UserName!)); + } + } + } + + return errors.Count > 0 ? IdentityResult.Failed(errors.ToArray()) : IdentityResult.Success; + } } } 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..cc88d3d792 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,55 @@ 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> GetUsersByLoginAsync( + [NotNull] string loginProvider, + [NotNull] string providerKey, + bool includeDetails = false, + CancellationToken cancellationToken = default + ); + + Task> GetUsersByPasskeyIdAsync( + [NotNull] byte[] credentialId, + 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 41da1e5c22..c2f72366fb 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 @@ -8,11 +8,13 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Volo.Abp.Caching; +using Volo.Abp.Data; using Volo.Abp.Domain.Entities; using Volo.Abp.Domain.Repositories; using Volo.Abp.Domain.Services; using Volo.Abp.EventBus.Distributed; using Volo.Abp.Identity.Settings; +using Volo.Abp.MultiTenancy; using Volo.Abp.Security.Claims; using Volo.Abp.Settings; using Volo.Abp.Threading; @@ -31,6 +33,9 @@ public class IdentityUserManager : UserManager, IDomainService protected IIdentityLinkUserRepository IdentityLinkUserRepository { get; } protected IDistributedCache DynamicClaimCache { get; } protected override CancellationToken CancellationToken => CancellationTokenProvider.Token; + protected IOptions MultiTenancyOptions { get; } + protected ICurrentTenant CurrentTenant { get; } + protected IDataFilter DataFilter { get; } public IdentityUserManager( IdentityUserStore store, @@ -49,7 +54,10 @@ public class IdentityUserManager : UserManager, IDomainService ISettingProvider settingProvider, IDistributedEventBus distributedEventBus, IIdentityLinkUserRepository identityLinkUserRepository, - IDistributedCache dynamicClaimCache) + IDistributedCache dynamicClaimCache, + IOptions multiTenancyOptions, + ICurrentTenant currentTenant, + IDataFilter dataFilter) : base( store, optionsAccessor, @@ -68,6 +76,9 @@ public class IdentityUserManager : UserManager, IDomainService UserRepository = userRepository; IdentityLinkUserRepository = identityLinkUserRepository; DynamicClaimCache = dynamicClaimCache; + MultiTenancyOptions = multiTenancyOptions; + CurrentTenant = currentTenant; + DataFilter = dataFilter; CancellationTokenProvider = cancellationTokenProvider; } @@ -116,7 +127,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 +140,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 +420,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); @@ -555,4 +595,127 @@ public class IdentityUserManager : UserManager, IDomainService Logger.LogError($"Could not get a valid user name for the given email address: {email}, allowed characters: {Options.User.AllowedUserNameCharacters}, tried {maxTryCount} times."); throw new AbpIdentityResultException(IdentityResult.Failed(ErrorDescriber.InvalidUserName(userName))); } + + public virtual async Task FindSharedUserByEmailAsync(string email) + { + if (MultiTenancyOptions.Value.UserSharingStrategy == TenantUserSharingStrategy.Isolated) + { + return await base.FindByEmailAsync(email); + } + + using (CurrentTenant.Change(null)) + { + using (DataFilter.Disable()) + { + var normalizedEmail = NormalizeEmail(email); + var hostusers = await UserRepository.GetUsersByNormalizedEmailAsync(normalizedEmail, cancellationToken: CancellationToken); + //host user first + var hostUser = hostusers.FirstOrDefault(x => x.TenantId == null) ?? hostusers.FirstOrDefault(x => x.TenantId != Guid.Empty) ?? hostusers.FirstOrDefault(); + if (hostUser == null) + { + return null; + } + + using (DataFilter.Enable()) + { + using (CurrentTenant.Change(hostUser.TenantId)) + { + return await base.FindByEmailAsync(email); + } + } + } + } + } + + public virtual async Task FindSharedUserByNameAsync(string userName) + { + if (MultiTenancyOptions.Value.UserSharingStrategy == TenantUserSharingStrategy.Isolated) + { + return await base.FindByNameAsync(userName); + } + + using (CurrentTenant.Change(null)) + { + using (DataFilter.Disable()) + { + var normalizeduserName = NormalizeName(userName); + var hostusers = await UserRepository.GetUsersByNormalizedUserNameAsync(normalizeduserName, cancellationToken: CancellationToken); + //host user first + var hostUser = hostusers.FirstOrDefault(x => x.TenantId == null) ?? hostusers.FirstOrDefault(x => x.TenantId != Guid.Empty) ?? hostusers.FirstOrDefault(); + if (hostUser == null) + { + return null; + } + + using (DataFilter.Enable()) + { + using (CurrentTenant.Change(hostUser.TenantId)) + { + return await base.FindByNameAsync(userName); + } + } + } + } + } + + public virtual async Task FindSharedUserByLoginAsync(string loginProvider, string providerKey) + { + if (MultiTenancyOptions.Value.UserSharingStrategy == TenantUserSharingStrategy.Isolated) + { + return await base.FindByLoginAsync(loginProvider, providerKey); + } + + using (CurrentTenant.Change(null)) + { + using (DataFilter.Disable()) + { + var hostusers = await UserRepository.GetUsersByLoginAsync(loginProvider, providerKey, cancellationToken: CancellationToken); + //host user first + var hostUser = hostusers.FirstOrDefault(x => x.TenantId == null) ?? hostusers.FirstOrDefault(x => x.TenantId != Guid.Empty) ?? hostusers.FirstOrDefault(); + if (hostUser == null) + { + return null; + } + + using (DataFilter.Enable()) + { + using (CurrentTenant.Change(hostUser.TenantId)) + { + return await base.FindByLoginAsync(loginProvider, providerKey); + } + } + } + } + } + + public virtual async Task FindSharedUserByPasskeyIdAsync(byte[] credentialId) + { + if (MultiTenancyOptions.Value.UserSharingStrategy == TenantUserSharingStrategy.Isolated) + { + return await base.FindByPasskeyIdAsync(credentialId); + } + + using (CurrentTenant.Change(null)) + { + using (DataFilter.Disable()) + { + var hostusers = await UserRepository.GetUsersByPasskeyIdAsync(credentialId, cancellationToken: CancellationToken); + //host user first + var hostUser = hostusers.FirstOrDefault(x => x.TenantId == null) ?? hostusers.FirstOrDefault(x => x.TenantId != Guid.Empty) ?? hostusers.FirstOrDefault(); + if (hostUser == null) + { + return null; + } + + using (DataFilter.Enable()) + { + using (CurrentTenant.Change(hostUser.TenantId)) + { + return await base.FindByPasskeyIdAsync(credentialId); + } + } + } + } + } + } 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..cd6fa072a1 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,84 @@ 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> GetUsersByLoginAsync(string loginProvider, string providerKey, bool includeDetails = false, CancellationToken cancellationToken = default) + { + return await (await GetDbSetAsync()) + .IncludeDetails(includeDetails) + .OrderBy(x => x.Id) + .Where(u => u.Logins.Any(login => login.LoginProvider == loginProvider && login.ProviderKey == providerKey)) + .ToListAsync(GetCancellationToken(cancellationToken)); + } + + public virtual async Task> GetUsersByPasskeyIdAsync(byte[] credentialId, bool includeDetails = false, CancellationToken cancellationToken = default) + { + return await (await GetDbSetAsync()) + .IncludeDetails(includeDetails) + .Where(u => u.Passkeys.Any(x => x.CredentialId.SequenceEqual(credentialId))) + .OrderBy(x => x.Id) + .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..9209946d7f 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,75 @@ 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> GetUsersByLoginAsync(string loginProvider, string providerKey, bool includeDetails = false, CancellationToken cancellationToken = default) + { + return await (await GetQueryableAsync(cancellationToken)) + .OrderBy(x => x.Id) + .Where(u => u.Logins.Any(login => login.LoginProvider == loginProvider && login.ProviderKey == providerKey)) + .ToListAsync(cancellationToken: cancellationToken); + } + + public virtual async Task> GetUsersByPasskeyIdAsync(byte[] credentialId, bool includeDetails = false, CancellationToken cancellationToken = default) + { + return await (await GetQueryableAsync(cancellationToken)) + .OrderBy(x => x.Id) + .Where(u => u.Passkeys.Any(x => x.CredentialId == credentialId)) + .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..752bc3496a 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,131 @@ public class AbpIdentityUserValidator_Tests : AbpIdentityAspNetCoreTestBase identityResult.Errors.First().Description.ShouldBe(Localizer["Volo.Abp.Identity:InvalidEmail", "user1@volosoft.com"]); } } + +public class AbpIdentityUserValidator_SharedUser_Compatible_Tests : AbpIdentityUserValidator_Tests +{ + protected override void ConfigureServices(HostBuilderContext context, IServiceCollection services) + { + services.Configure(options => + { + options.IsEnabled = true; + options.UserSharingStrategy = TenantUserSharingStrategy.Shared; + }); + } +} + +public class AbpIdentityUserValidator_SharedUser_Tests : AbpIdentityAspNetCoreTestBase +{ + private readonly IdentityUserManager _identityUserManager; + private readonly ICurrentTenant _currentTenant; + + public AbpIdentityUserValidator_SharedUser_Tests() + { + _identityUserManager = GetRequiredService(); + _currentTenant = GetRequiredService(); + } + + protected override void ConfigureServices(HostBuilderContext context, IServiceCollection services) + { + services.Configure(options => + { + options.IsEnabled = true; + options.UserSharingStrategy = TenantUserSharingStrategy.Shared; + }); + } + + [Fact] + public async Task Should_Reject_Duplicate_UserName_Across_Tenants() + { + var tenant1Id = Guid.NewGuid(); + var tenant2Id = Guid.NewGuid(); + + using (_currentTenant.Change(tenant1Id)) + { + var user1 = new IdentityUser(Guid.NewGuid(), "shared-user", "shared-user-1@volosoft.com"); + (await _identityUserManager.CreateAsync(user1)).Succeeded.ShouldBeTrue(); + } + + using (_currentTenant.Change(tenant2Id)) + { + var user2 = new IdentityUser(Guid.NewGuid(), "shared-user", "shared-user-2@volosoft.com"); + var result = await _identityUserManager.CreateAsync(user2); + + result.Succeeded.ShouldBeFalse(); + result.Errors.Count().ShouldBe(1); + result.Errors.First().Code.ShouldBe("DuplicateUserName"); + } + } + + [Fact] + public async Task Should_Reject_Duplicate_Email_Across_Tenants() + { + var tenant1Id = Guid.NewGuid(); + var tenant2Id = Guid.NewGuid(); + const string sharedEmail = "shared-email@volosoft.com"; + + using (_currentTenant.Change(tenant1Id)) + { + var user1 = new IdentityUser(Guid.NewGuid(), "shared-email-user-1", sharedEmail); + (await _identityUserManager.CreateAsync(user1)).Succeeded.ShouldBeTrue(); + } + + using (_currentTenant.Change(tenant2Id)) + { + var user2 = new IdentityUser(Guid.NewGuid(), "shared-email-user-2", sharedEmail); + var result = await _identityUserManager.CreateAsync(user2); + + result.Succeeded.ShouldBeFalse(); + result.Errors.Count().ShouldBe(1); + result.Errors.First().Code.ShouldBe("DuplicateEmail"); + } + } + + [Fact] + public async Task Should_Reject_UserName_That_Matches_Another_Users_Email_Across_Tenants() + { + var tenant1Id = Guid.NewGuid(); + var tenant2Id = Guid.NewGuid(); + const string sharedValue = "conflict@volosoft.com"; + + using (_currentTenant.Change(tenant1Id)) + { + var user1 = new IdentityUser(Guid.NewGuid(), "unique-user", sharedValue); + (await _identityUserManager.CreateAsync(user1)).Succeeded.ShouldBeTrue(); + } + + using (_currentTenant.Change(tenant2Id)) + { + var user2 = new IdentityUser(Guid.NewGuid(), sharedValue, "another@volosoft.com"); + var result = await _identityUserManager.CreateAsync(user2); + + result.Succeeded.ShouldBeFalse(); + result.Errors.Count().ShouldBe(1); + result.Errors.First().Code.ShouldBe("InvalidUserName"); + } + } + + [Fact] + public async Task Should_Reject_Email_That_Matches_Another_Users_UserName_Across_Tenants() + { + var tenant1Id = Guid.NewGuid(); + var tenant2Id = Guid.NewGuid(); + const string sharedValue = "conflict-user"; + + using (_currentTenant.Change(tenant1Id)) + { + var user1 = new IdentityUser(Guid.NewGuid(), sharedValue, "conflict-user-1@volosoft.com"); + (await _identityUserManager.CreateAsync(user1)).Succeeded.ShouldBeTrue(); + } + + using (_currentTenant.Change(tenant2Id)) + { + var user2 = new IdentityUser(Guid.NewGuid(), "another-user", sharedValue); + var result = await _identityUserManager.CreateAsync(user2); + + result.Succeeded.ShouldBeFalse(); + result.Errors.Count().ShouldBe(1); + result.Errors.First().Code.ShouldBe("InvalidEmail"); + } + } +} diff --git a/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/AbpIdentityErrorDescriber_Tests.cs b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/AbpIdentityErrorDescriber_Tests.cs index a7f68ec0b2..18da5ae9ce 100644 --- a/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/AbpIdentityErrorDescriber_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/AbpIdentityErrorDescriber_Tests.cs @@ -200,9 +200,12 @@ public class AbpIdentityErrorDescriber_Tests : AbpIdentityDomainTestBase var mismatchUser = new IdentityUser(Guid.NewGuid(), "mismatch_user_en", "mismatch_user_en@abp.io"); (await userManager.CreateAsync(mismatchUser, "Abp123!")).Succeeded.ShouldBeTrue(); - var mismatchResult = await userManager.ChangePasswordAsync(mismatchUser, "WrongOld123!", "NewAbp123!"); - mismatchResult.Succeeded.ShouldBeFalse(); - mismatchResult.Errors.ShouldContain(e => e.Description == "Incorrect password."); + var identityException = await Assert.ThrowsAsync(async () => + { + await userManager.ChangePasswordAsync(mismatchUser, "WrongOld123!", "NewAbp123!"); + }); + identityException.IdentityResult.Succeeded.ShouldBeFalse(); + identityException.IdentityResult.Errors.ShouldContain(e => e.Description == "Incorrect password."); var recoveryUser = new IdentityUser(Guid.NewGuid(), "recovery_user_en", "recovery_user_en@abp.io"); ObjectHelper.TrySetProperty(recoveryUser, x => x.TwoFactorEnabled, () => true); @@ -321,9 +324,12 @@ public class AbpIdentityErrorDescriber_Tests : AbpIdentityDomainTestBase var mismatchUser = new IdentityUser(Guid.NewGuid(), "mismatch_user_tr", "mismatch_user_tr@abp.io"); (await userManager.CreateAsync(mismatchUser, "Abp123!")).Succeeded.ShouldBeTrue(); - var mismatchResult = await userManager.ChangePasswordAsync(mismatchUser, "WrongOld123!", "NewAbp123!"); - mismatchResult.Succeeded.ShouldBeFalse(); - mismatchResult.Errors.ShouldContain(e => e.Description == "Hatalı şifre."); + var identityException = await Assert.ThrowsAsync(async () => + { + await userManager.ChangePasswordAsync(mismatchUser, "WrongOld123!", "NewAbp123!"); + }); + identityException.IdentityResult.Succeeded.ShouldBeFalse(); + identityException.IdentityResult.Errors.ShouldContain(e => e.Description == "Hatalı şifre."); var recoveryUser = new IdentityUser(Guid.NewGuid(), "recovery_user_tr", "recovery_user_tr@abp.io"); ObjectHelper.TrySetProperty(recoveryUser, x => x.TwoFactorEnabled, () => true); @@ -441,9 +447,12 @@ public class AbpIdentityErrorDescriber_Tests : AbpIdentityDomainTestBase var mismatchUser = new IdentityUser(Guid.NewGuid(), "mismatch_user_zh", "mismatch_user_zh@abp.io"); (await userManager.CreateAsync(mismatchUser, "Abp123!")).Succeeded.ShouldBeTrue(); - var mismatchResult = await userManager.ChangePasswordAsync(mismatchUser, "WrongOld123!", "NewAbp123!"); - mismatchResult.Succeeded.ShouldBeFalse(); - mismatchResult.Errors.ShouldContain(e => e.Description == "密码错误。"); + var identityException = await Assert.ThrowsAsync(async () => + { + await userManager.ChangePasswordAsync(mismatchUser, "WrongOld123!", "NewAbp123!"); + }); + identityException.IdentityResult.Succeeded.ShouldBeFalse(); + identityException.IdentityResult.Errors.ShouldContain(e => e.Description == "密码错误。"); var recoveryUser = new IdentityUser(Guid.NewGuid(), "recovery_user_zh", "recovery_user_zh@abp.io"); ObjectHelper.TrySetProperty(recoveryUser, x => x.TwoFactorEnabled, () => true); diff --git a/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserManager_Tests.cs b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserManager_Tests.cs index da26de3e3d..48da394a05 100644 --- a/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserManager_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserManager_Tests.cs @@ -490,3 +490,148 @@ public class IdentityUserManager_Tests : AbpIdentityDomainTestBase TestSettingValueProvider.AddSetting(IdentitySettingNames.Password.ForceUsersToPeriodicallyChangePassword, true.ToString()); } } + +public class SharedTenantUserSharingStrategy_IdentityUserManager_Tests : AbpIdentityDomainTestBase +{ + private readonly IdentityUserManager _identityUserManager; + private readonly IIdentityUserRepository _identityUserRepository; + private readonly ICurrentTenant _currentTenant; + private readonly IUnitOfWorkManager _unitOfWorkManager; + + public SharedTenantUserSharingStrategy_IdentityUserManager_Tests() + { + _identityUserManager = GetRequiredService(); + _identityUserRepository = GetRequiredService(); + _currentTenant = GetRequiredService(); + _unitOfWorkManager = GetRequiredService(); + } + + protected override void AfterAddApplication(IServiceCollection services) + { + services.Configure(options => + { + options.IsEnabled = true; + options.UserSharingStrategy = TenantUserSharingStrategy.Shared; + }); + } + + [Fact] + public async Task FindSharedUserByEmailAsync_Should_Return_Host_User() + { + var tenantId = Guid.NewGuid(); + var email = "shared-email@abp.io"; + + using (var uow = _unitOfWorkManager.Begin()) + { + await CreateUserAsync(null, "shared-host-email", email); + await CreateUserAsync(tenantId, "shared-tenant-email", email); + await uow.CompleteAsync(); + } + + using (_currentTenant.Change(tenantId)) + { + var user = await _identityUserManager.FindSharedUserByEmailAsync(email); + + user.ShouldNotBeNull(); + user.TenantId.ShouldBeNull(); + user.UserName.ShouldBe("shared-host-email"); + } + } + + [Fact] + public async Task FindSharedUserByNameAsync_Should_Return_Host_User() + { + var tenantId = Guid.NewGuid(); + var userName = "shared-user-name"; + + using (var uow = _unitOfWorkManager.Begin()) + { + await CreateUserAsync(null, userName, "shared-host-name@abp.io"); + await CreateUserAsync(tenantId, userName, "shared-tenant-name@abp.io"); + await uow.CompleteAsync(); + } + + using (_currentTenant.Change(tenantId)) + { + var user = await _identityUserManager.FindSharedUserByNameAsync(userName); + + user.ShouldNotBeNull(); + user.TenantId.ShouldBeNull(); + user.UserName.ShouldBe(userName); + } + } + + [Fact] + public async Task FindSharedUserByLoginAsync_Should_Return_Host_User() + { + var tenantId = Guid.NewGuid(); + const string loginProvider = "github"; + const string providerKey = "shared-login"; + + using (var uow = _unitOfWorkManager.Begin()) + { + await CreateUserAsync(null, "shared-host-login", "shared-host-login@abp.io", user => + { + user.AddLogin(new UserLoginInfo(loginProvider, providerKey, "Shared Login")); + }); + + await CreateUserAsync(tenantId, "shared-tenant-login", "shared-tenant-login@abp.io", user => + { + user.AddLogin(new UserLoginInfo(loginProvider, providerKey, "Shared Login")); + }); + + await uow.CompleteAsync(); + } + + using (_currentTenant.Change(tenantId)) + { + var user = await _identityUserManager.FindSharedUserByLoginAsync(loginProvider, providerKey); + + user.ShouldNotBeNull(); + user.TenantId.ShouldBeNull(); + user.UserName.ShouldBe("shared-host-login"); + } + } + + [Fact] + public async Task FindSharedUserByPasskeyIdAsync_Should_Return_Host_User() + { + var tenantId = Guid.NewGuid(); + var credentialId = new byte[] { 10, 20, 30, 40, 50, 60 }; + + using (var uow = _unitOfWorkManager.Begin()) + { + await CreateUserAsync(null, "shared-host-passkey", "shared-host-passkey@abp.io", user => + { + user.AddPasskey(credentialId, new IdentityPasskeyData()); + }); + await uow.CompleteAsync(); + } + + using (_currentTenant.Change(tenantId)) + { + var user = await _identityUserManager.FindSharedUserByPasskeyIdAsync(credentialId); + + user.ShouldNotBeNull(); + user.TenantId.ShouldBeNull(); + user.UserName.ShouldBe("shared-host-passkey"); + } + } + + private async Task CreateUserAsync( + Guid? tenantId, + string userName, + string email, + Action? configureUser = null) + { + var user = new IdentityUser(Guid.NewGuid(), userName, email, tenantId); + configureUser?.Invoke(user); + + using (_currentTenant.Change(tenantId)) + { + await _identityUserRepository.InsertAsync(user); + } + + return user; + } +} diff --git a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserRepository_Tests.cs b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserRepository_Tests.cs index 58dc072359..762ceb5ff3 100644 --- a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserRepository_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserRepository_Tests.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Shouldly; using Volo.Abp.Modularity; +using Volo.Abp.MultiTenancy; using Xunit; namespace Volo.Abp.Identity; @@ -19,6 +20,7 @@ public abstract class IdentityUserRepository_Tests : AbpIdentity protected IOrganizationUnitRepository OrganizationUnitRepository { get; } protected OrganizationUnitManager OrganizationUnitManager { get; } protected IdentityTestData TestData { get; } + protected ICurrentTenant CurrentTenant { get; } protected IdentityUserRepository_Tests() { @@ -28,6 +30,7 @@ public abstract class IdentityUserRepository_Tests : AbpIdentity OrganizationUnitRepository = GetRequiredService(); OrganizationUnitManager = GetRequiredService();; TestData = ServiceProvider.GetRequiredService(); + CurrentTenant = GetRequiredService(); } [Fact] @@ -313,4 +316,140 @@ public abstract class IdentityUserRepository_Tests : AbpIdentity (await UserRepository.FindByPasskeyIdAsync((byte[])[1, 2, 3])).ShouldBeNull(); } + + [Fact] + public async Task GetUsersByNormalizedUserNameAsync() + { + var users = await UserRepository.GetUsersByNormalizedUserNameAsync( + LookupNormalizer.NormalizeName("john.nash") + ); + + users.ShouldContain(u => u.Id == TestData.UserJohnId); + + users = await UserRepository.GetUsersByNormalizedUserNameAsync( + LookupNormalizer.NormalizeName("undefined-user") + ); + users.Count.ShouldBe(0); + } + + [Fact] + public async Task GetUsersByNormalizedUserNamesAsync() + { + var users = await UserRepository.GetUsersByNormalizedUserNamesAsync(new[] + { + LookupNormalizer.NormalizeName("john.nash"), + LookupNormalizer.NormalizeName("neo"), + LookupNormalizer.NormalizeName("undefined-user") + }); + + users.Count.ShouldBe(2); + users.ShouldContain(u => u.Id == TestData.UserJohnId); + users.ShouldContain(u => u.Id == TestData.UserNeoId); + } + + [Fact] + public async Task GetUsersByNormalizedEmailAsync() + { + var users = await UserRepository.GetUsersByNormalizedEmailAsync( + LookupNormalizer.NormalizeEmail("john.nash@abp.io") + ); + + users.ShouldContain(u => u.Id == TestData.UserJohnId); + + users = await UserRepository.GetUsersByNormalizedEmailAsync( + LookupNormalizer.NormalizeEmail("undefined-user@abp.io") + ); + users.Count.ShouldBe(0); + } + + [Fact] + public async Task GetUsersByNormalizedEmailsAsync() + { + var users = await UserRepository.GetUsersByNormalizedEmailsAsync(new[] + { + LookupNormalizer.NormalizeEmail("john.nash@abp.io"), + LookupNormalizer.NormalizeEmail("neo@abp.io"), + LookupNormalizer.NormalizeEmail("undefined-user@abp.io") + }); + + users.Count.ShouldBe(2); + users.ShouldContain(u => u.Id == TestData.UserJohnId); + users.ShouldContain(u => u.Id == TestData.UserNeoId); + } + + [Fact] + public async Task GetUsersByLoginAsync() + { + var users = await UserRepository.GetUsersByLoginAsync("github", "john"); + users.Count.ShouldBe(1); + users.ShouldContain(u => u.Id == TestData.UserJohnId); + + users = await UserRepository.GetUsersByLoginAsync("github", "undefined-user"); + users.Count.ShouldBe(0); + } + + [Fact] + public async Task GetUsersByPasskeyIdAsync() + { + var users = await UserRepository.GetUsersByPasskeyIdAsync(TestData.PasskeyCredentialId1); + users.Count.ShouldBe(1); + users.ShouldContain(u => u.Id == TestData.UserJohnId); + + users = await UserRepository.GetUsersByPasskeyIdAsync(TestData.PasskeyCredentialId3); + users.Count.ShouldBe(1); + users.ShouldContain(u => u.Id == TestData.UserNeoId); + + users = await UserRepository.GetUsersByPasskeyIdAsync((byte[])[1, 2, 3]); + users.Count.ShouldBe(0); + } + + [Fact] + public async Task FindByNormalizedUserNameAsync_With_TenantId() + { + var tenantId = Guid.NewGuid(); + var tenantUser = new IdentityUser(Guid.NewGuid(), "tenant.user", "tenant.user@abp.io", tenantId); + + await UserRepository.InsertAsync(tenantUser, autoSave: true); + + using (CurrentTenant.Change(tenantId)) + { + var user = await UserRepository.FindByNormalizedUserNameAsync( + tenantId, + LookupNormalizer.NormalizeName("tenant.user") + ); + + user.ShouldNotBeNull(); + user.Id.ShouldBe(tenantUser.Id); + } + + (await UserRepository.FindByNormalizedUserNameAsync( + tenantId, + LookupNormalizer.NormalizeName("tenant.user") + )).ShouldBeNull(); + } + + [Fact] + public async Task FindByNormalizedEmailAsync_With_TenantId() + { + var tenantId = Guid.NewGuid(); + var tenantUser = new IdentityUser(Guid.NewGuid(), "tenant.email", "tenant.email@abp.io", tenantId); + + await UserRepository.InsertAsync(tenantUser, autoSave: true); + + using (CurrentTenant.Change(tenantId)) + { + var user = await UserRepository.FindByNormalizedEmailAsync( + tenantId, + LookupNormalizer.NormalizeEmail("tenant.email@abp.io") + ); + + user.ShouldNotBeNull(); + user.Id.ShouldBe(tenantUser.Id); + } + + (await UserRepository.FindByNormalizedEmailAsync( + tenantId, + LookupNormalizer.NormalizeEmail("tenant.email@abp.io") + )).ShouldBeNull(); + } } 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..f47b1ea0e9 --- /dev/null +++ b/modules/users/src/Volo.Abp.Users.Abstractions/Volo/Abp/Users/InviteUserToTenantRequestedEto.cs @@ -0,0 +1,16 @@ +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; } + + public bool DirectlyAddToTenant { get; set; } +}