From fa9e4f13cfc2ef61483a18772323e64d45e6f6c9 Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 21 Apr 2026 21:09:00 +0800 Subject: [PATCH 01/13] Support shared mode lookup by id for two-factor authentication user - Add IdentityUserManager.FindSharedUserByIdAsync to resolve a user by id across tenants in shared user sharing strategy - Override AbpSignInManager.GetTwoFactorAuthenticationUserAsync to use it so the 2FA mid-flow can still find a tenant-scoped user when CurrentTenant is host - Cover the new method with unit tests --- .../Identity/AspNetCore/AbpSignInManager.cs | 20 ++++++- .../Volo/Abp/Identity/IdentityUserManager.cs | 27 +++++++++ .../Abp/Identity/IdentityUserManager_Tests.cs | 60 +++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) 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 4b3147c14e..e6f56c8dec 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 @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.Security.Claims; +using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; @@ -133,6 +134,23 @@ public class AbpSignInManager : SignInManager return await IdentityUserManager.FindSharedUserByLoginAsync(loginProvider, providerKey); } + public override async Task GetTwoFactorAuthenticationUserAsync() + { + var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorUserIdScheme); + if (result?.Principal == null) + { + return null; + } + + var userId = result.Principal.FindFirstValue(ClaimTypes.Name); + if (string.IsNullOrWhiteSpace(userId)) + { + return null; + } + + return await IdentityUserManager.FindSharedUserByIdAsync(userId); + } + /// /// This is to call the protection method PreSignInCheck /// 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 c2f72366fb..46f7a635f0 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 @@ -718,4 +718,31 @@ public class IdentityUserManager : UserManager, IDomainService } } + public virtual async Task FindSharedUserByIdAsync(string userId) + { + if (MultiTenancyOptions.Value.UserSharingStrategy == TenantUserSharingStrategy.Isolated) + { + return await base.FindByIdAsync(userId); + } + + using (CurrentTenant.Change(null)) + { + using (DataFilter.Disable()) + { + var user = await base.FindByIdAsync(userId); + if (user == null) + { + return null; + } + + using (DataFilter.Enable()) + { + using (CurrentTenant.Change(user.TenantId)) + { + return await base.FindByIdAsync(userId); + } + } + } + } + } } 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 48da394a05..a62a9cdff8 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 @@ -618,6 +618,66 @@ public class SharedTenantUserSharingStrategy_IdentityUserManager_Tests : AbpIden } } + [Fact] + public async Task FindSharedUserByIdAsync_Should_Find_Tenant_User_From_Host_Context() + { + var tenantId = Guid.NewGuid(); + IdentityUser tenantUser; + + using (var uow = _unitOfWorkManager.Begin()) + { + tenantUser = await CreateUserAsync(tenantId, "shared-id-tenant-only", "shared-id-tenant-only@abp.io"); + await uow.CompleteAsync(); + } + + // Simulates the 2FA mid-flow on a Shared deployment: CurrentTenant is null + // but the user row only exists under a tenant. FindByIdAsync alone would be + // filtered out by the IMultiTenant filter, so FindSharedUserByIdAsync must + // disable the filter and still return the tenant user. + using (_currentTenant.Change(null)) + { + var user = await _identityUserManager.FindSharedUserByIdAsync(tenantUser.Id.ToString()); + + user.ShouldNotBeNull(); + user.Id.ShouldBe(tenantUser.Id); + user.TenantId.ShouldBe(tenantId); + user.UserName.ShouldBe("shared-id-tenant-only"); + } + } + + [Fact] + public async Task FindSharedUserByIdAsync_Should_Find_Host_User_From_Tenant_Context() + { + var tenantId = Guid.NewGuid(); + IdentityUser hostUser; + + using (var uow = _unitOfWorkManager.Begin()) + { + hostUser = await CreateUserAsync(null, "shared-id-host-only", "shared-id-host-only@abp.io"); + await uow.CompleteAsync(); + } + + using (_currentTenant.Change(tenantId)) + { + var user = await _identityUserManager.FindSharedUserByIdAsync(hostUser.Id.ToString()); + + user.ShouldNotBeNull(); + user.Id.ShouldBe(hostUser.Id); + user.TenantId.ShouldBeNull(); + user.UserName.ShouldBe("shared-id-host-only"); + } + } + + [Fact] + public async Task FindSharedUserByIdAsync_Should_Return_Null_For_Unknown_Id() + { + using (_currentTenant.Change(null)) + { + var user = await _identityUserManager.FindSharedUserByIdAsync(Guid.NewGuid().ToString()); + user.ShouldBeNull(); + } + } + private async Task CreateUserAsync( Guid? tenantId, string userName, From cb5c17b124d181bc34481df330b74197980205d9 Mon Sep 17 00:00:00 2001 From: maliming Date: Wed, 22 Apr 2026 10:08:22 +0800 Subject: [PATCH 02/13] Add integration test for GetTwoFactorAuthenticationUserAsync in shared mode Exercises the full cookie round-trip: writes a TwoFactorUserId cookie carrying a tenant user id, then verifies that AbpSignInManager.GetTwoFactorAuthenticationUserAsync returns the tenant user when CurrentTenant is null. --- .../GetTwoFactorAuthenticationUser_Tests.cs | 62 +++++++++++++++++++ .../SharedAbpIdentityAspNetCoreTestBase.cs | 7 +++ .../SharedAbpIdentityAspNetCoreTestModule.cs | 17 +++++ .../SharedAbpIdentityAspNetCoreTestStartup.cs | 18 ++++++ .../AspNetCore/SignInTestController.cs | 20 +++++- 5 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/GetTwoFactorAuthenticationUser_Tests.cs create mode 100644 modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SharedAbpIdentityAspNetCoreTestBase.cs create mode 100644 modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SharedAbpIdentityAspNetCoreTestModule.cs create mode 100644 modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SharedAbpIdentityAspNetCoreTestStartup.cs diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/GetTwoFactorAuthenticationUser_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/GetTwoFactorAuthenticationUser_Tests.cs new file mode 100644 index 0000000000..267464c646 --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/GetTwoFactorAuthenticationUser_Tests.cs @@ -0,0 +1,62 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Uow; +using Xunit; + +namespace Volo.Abp.Identity.AspNetCore; + +public class GetTwoFactorAuthenticationUser_Tests : SharedAbpIdentityAspNetCoreTestBase +{ + [Fact] + public async Task Should_Resolve_Tenant_User_By_Id_When_Current_Tenant_Is_Host() + { + var userRepository = GetRequiredService(); + var currentTenant = GetRequiredService(); + var unitOfWorkManager = GetRequiredService(); + + var tenantId = Guid.NewGuid(); + Guid tenantUserId; + + using (var uow = unitOfWorkManager.Begin()) + { + using (currentTenant.Change(tenantId)) + { + var user = new IdentityUser(Guid.NewGuid(), "shared-2fa-tenant-user", "shared-2fa-tenant-user@abp.io", tenantId); + await userRepository.InsertAsync(user); + tenantUserId = user.Id; + } + await uow.CompleteAsync(); + } + + var writeResponse = await Client.GetAsync($"/api/signin-test/write-two-factor-cookie?userId={tenantUserId}"); + writeResponse.EnsureSuccessStatusCode(); + + if (writeResponse.Headers.TryGetValues("Set-Cookie", out var setCookies)) + { + foreach (var cookie in setCookies) + { + Client.DefaultRequestHeaders.Add("Cookie", cookie.Split(';').First()); + } + } + + var getResponse = await Client.GetAsync("/api/signin-test/get-two-factor-user"); + getResponse.EnsureSuccessStatusCode(); + var content = await getResponse.Content.ReadAsStringAsync(); + + content.ShouldBe(tenantUserId.ToString()); + } + + [Fact] + public async Task Should_Return_Null_When_No_Two_Factor_Cookie() + { + var getResponse = await Client.GetAsync("/api/signin-test/get-two-factor-user"); + getResponse.EnsureSuccessStatusCode(); + var content = await getResponse.Content.ReadAsStringAsync(); + + content.ShouldBe("null"); + } +} diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SharedAbpIdentityAspNetCoreTestBase.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SharedAbpIdentityAspNetCoreTestBase.cs new file mode 100644 index 0000000000..451e56d268 --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SharedAbpIdentityAspNetCoreTestBase.cs @@ -0,0 +1,7 @@ +using Volo.Abp.AspNetCore.TestBase; + +namespace Volo.Abp.Identity.AspNetCore; + +public abstract class SharedAbpIdentityAspNetCoreTestBase : AbpAspNetCoreIntegratedTestBase +{ +} diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SharedAbpIdentityAspNetCoreTestModule.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SharedAbpIdentityAspNetCoreTestModule.cs new file mode 100644 index 0000000000..8ad2d55bc3 --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SharedAbpIdentityAspNetCoreTestModule.cs @@ -0,0 +1,17 @@ +using Volo.Abp.Modularity; +using Volo.Abp.MultiTenancy; + +namespace Volo.Abp.Identity.AspNetCore; + +[DependsOn(typeof(AbpIdentityAspNetCoreTestModule))] +public class SharedAbpIdentityAspNetCoreTestModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + Configure(options => + { + options.IsEnabled = true; + options.UserSharingStrategy = TenantUserSharingStrategy.Shared; + }); + } +} diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SharedAbpIdentityAspNetCoreTestStartup.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SharedAbpIdentityAspNetCoreTestStartup.cs new file mode 100644 index 0000000000..9e23739708 --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SharedAbpIdentityAspNetCoreTestStartup.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Volo.Abp.Identity.AspNetCore; + +public class SharedAbpIdentityAspNetCoreTestStartup +{ + public void ConfigureServices(IServiceCollection services) + { + services.AddApplication(); + } + + public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory) + { + app.InitializeApplication(); + } +} diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SignInTestController.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SignInTestController.cs index bfc0c6d51e..fe77766cb3 100644 --- a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SignInTestController.cs +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SignInTestController.cs @@ -1,4 +1,6 @@ -using System.Threading.Tasks; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Volo.Abp.AspNetCore.Mvc; @@ -27,4 +29,20 @@ public class SignInTestController : AbpController return Content(result.ToString()); } + + [Route("write-two-factor-cookie")] + public async Task WriteTwoFactorCookie(string userId) + { + var identity = new ClaimsIdentity(IdentityConstants.TwoFactorUserIdScheme); + identity.AddClaim(new Claim(ClaimTypes.Name, userId)); + await HttpContext.SignInAsync(IdentityConstants.TwoFactorUserIdScheme, new ClaimsPrincipal(identity)); + return Content("OK"); + } + + [Route("get-two-factor-user")] + public async Task GetTwoFactorUser() + { + var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); + return Content(user?.Id.ToString() ?? "null"); + } } From 7fd2d44dd714da22fe722d9e9122994968d6ba04 Mon Sep 17 00:00:00 2001 From: maliming Date: Wed, 22 Apr 2026 10:12:25 +0800 Subject: [PATCH 03/13] Add contract test covering login-then-two-factor lookup chain Guards against regressing the data-access contract behind the 2FA redirect bug: login must find a tenant user by user name from a host context, and the 2FA mid-flow must then resolve the same tenant user by id from the same host context. --- .../Abp/Identity/IdentityUserManager_Tests.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 a62a9cdff8..1982080cf1 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 @@ -678,6 +678,34 @@ public class SharedTenantUserSharingStrategy_IdentityUserManager_Tests : AbpIden } } + [Fact] + public async Task Login_Then_TwoFactor_MidFlow_Should_Resolve_Same_Tenant_User_In_Shared_Mode() + { + // Covers the data-access contract behind the 2FA redirect bug: + // 1. login lookup (by user name) must find a tenant user from a host context, + // 2. the 2FA mid-flow lookup (by id) must then return the same tenant user + // from the same host context. Regressing either side re-opens the bug. + var tenantId = Guid.NewGuid(); + + using (var uow = _unitOfWorkManager.Begin()) + { + await CreateUserAsync(tenantId, "shared-2fa-linked", "shared-2fa-linked@abp.io"); + await uow.CompleteAsync(); + } + + using (_currentTenant.Change(null)) + { + var loginUser = await _identityUserManager.FindSharedUserByNameAsync("shared-2fa-linked"); + loginUser.ShouldNotBeNull(); + loginUser.TenantId.ShouldBe(tenantId); + + var twoFactorUser = await _identityUserManager.FindSharedUserByIdAsync(loginUser.Id.ToString()); + twoFactorUser.ShouldNotBeNull(); + twoFactorUser.Id.ShouldBe(loginUser.Id); + twoFactorUser.TenantId.ShouldBe(tenantId); + } + } + private async Task CreateUserAsync( Guid? tenantId, string userName, From db07204b7ad2c6c4ccf03d2b4b3a83508a6e871c Mon Sep 17 00:00:00 2001 From: maliming Date: Wed, 22 Apr 2026 10:51:08 +0800 Subject: [PATCH 04/13] Make UserManager.FindByIdAsync shared-aware by default - Override IdentityUserManager.FindByIdAsync to fall back to a cross-tenant lookup in shared user sharing strategy so any caller that hits FindByIdAsync from a non-matching tenant context (including base SignInManager internals for TwoFactorSignInAsync and TwoFactorRecoveryCodeSignInAsync) can still resolve a tenant user by id - Drop the now-redundant AbpSignInManager.GetTwoFactorAuthenticationUserAsync override; the base implementation works automatically through the new FindByIdAsync behavior - Cover the new FindByIdAsync behavior with unit tests --- .../Identity/AspNetCore/AbpSignInManager.cs | 20 +------- .../Volo/Abp/Identity/IdentityUserManager.cs | 17 +++++++ .../Abp/Identity/IdentityUserManager_Tests.cs | 49 +++++++++++++++++++ 3 files changed, 67 insertions(+), 19 deletions(-) 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 e6f56c8dec..4b3147c14e 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 @@ -1,5 +1,4 @@ -using System.Security.Claims; -using System.Threading.Tasks; +using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; @@ -134,23 +133,6 @@ public class AbpSignInManager : SignInManager return await IdentityUserManager.FindSharedUserByLoginAsync(loginProvider, providerKey); } - public override async Task GetTwoFactorAuthenticationUserAsync() - { - var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorUserIdScheme); - if (result?.Principal == null) - { - return null; - } - - var userId = result.Principal.FindFirstValue(ClaimTypes.Name); - if (string.IsNullOrWhiteSpace(userId)) - { - return null; - } - - return await IdentityUserManager.FindSharedUserByIdAsync(userId); - } - /// /// This is to call the protection method PreSignInCheck /// 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 46f7a635f0..c0470eef80 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 @@ -745,4 +745,21 @@ public class IdentityUserManager : UserManager, IDomainService } } } + + public override async Task FindByIdAsync(string userId) + { + // In shared user sharing strategy, a user id is globally unique. Fall back to + // a cross-tenant lookup so callers that hit FindByIdAsync from a non-matching + // tenant context (notably the 2FA mid-flow invoked by ASP.NET Core Identity + // base SignInManager paths) can still resolve the user by id. + // Downstream operations on the returned entity should still be scoped to + // user.TenantId explicitly via CurrentTenant.Change. + var user = await base.FindByIdAsync(userId); + if (user != null || MultiTenancyOptions.Value.UserSharingStrategy == TenantUserSharingStrategy.Isolated) + { + return user; + } + + return await FindSharedUserByIdAsync(userId); + } } 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 1982080cf1..803bc258a3 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 @@ -678,6 +678,55 @@ public class SharedTenantUserSharingStrategy_IdentityUserManager_Tests : AbpIden } } + [Fact] + public async Task FindByIdAsync_Should_Fall_Back_To_Cross_Tenant_Lookup_In_Shared_Mode() + { + // In shared user sharing strategy, UserManager.FindByIdAsync must resolve a + // tenant user even when CurrentTenant is host. This protects any caller that + // hits FindByIdAsync without an explicit shared-aware wrapper (e.g. ASP.NET + // Core Identity SignInManager internals for TwoFactorSignInAsync and + // TwoFactorRecoveryCodeSignInAsync). + var tenantId = Guid.NewGuid(); + IdentityUser tenantUser; + + using (var uow = _unitOfWorkManager.Begin()) + { + tenantUser = await CreateUserAsync(tenantId, "shared-findbyid-fallback", "shared-findbyid-fallback@abp.io"); + await uow.CompleteAsync(); + } + + using (_currentTenant.Change(null)) + { + var user = await _identityUserManager.FindByIdAsync(tenantUser.Id.ToString()); + + user.ShouldNotBeNull(); + user.Id.ShouldBe(tenantUser.Id); + user.TenantId.ShouldBe(tenantId); + } + } + + [Fact] + public async Task FindByIdAsync_Should_Return_Current_Tenant_Result_Without_Fallback_When_Found() + { + var tenantId = Guid.NewGuid(); + IdentityUser tenantUser; + + using (var uow = _unitOfWorkManager.Begin()) + { + tenantUser = await CreateUserAsync(tenantId, "shared-findbyid-hit", "shared-findbyid-hit@abp.io"); + await uow.CompleteAsync(); + } + + using (_currentTenant.Change(tenantId)) + { + var user = await _identityUserManager.FindByIdAsync(tenantUser.Id.ToString()); + + user.ShouldNotBeNull(); + user.Id.ShouldBe(tenantUser.Id); + user.TenantId.ShouldBe(tenantId); + } + } + [Fact] public async Task Login_Then_TwoFactor_MidFlow_Should_Resolve_Same_Tenant_User_In_Shared_Mode() { From 8ea6b9f025f2fc04126a6a0aaec0d673ff69c57e Mon Sep 17 00:00:00 2001 From: maliming Date: Wed, 22 Apr 2026 11:37:03 +0800 Subject: [PATCH 05/13] Make 2FA flows tenant-aware and enforce pre-checks --- .../Identity/AspNetCore/AbpSignInManager.cs | 58 ++++++++++++- .../Volo/Abp/Identity/IdentityUserManager.cs | 17 ---- .../GetTwoFactorAuthenticationUser_Tests.cs | 83 +++++++++++++++++++ .../AspNetCore/SignInTestController.cs | 14 ++++ .../Abp/Identity/IdentityUserManager_Tests.cs | 49 ----------- 5 files changed, 154 insertions(+), 67 deletions(-) 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 4b3147c14e..c8d3b5c275 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 @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.Security.Claims; +using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; @@ -133,6 +134,61 @@ public class AbpSignInManager : SignInManager return await IdentityUserManager.FindSharedUserByLoginAsync(loginProvider, providerKey); } + public override async Task GetTwoFactorAuthenticationUserAsync() + { + var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorUserIdScheme); + if (result?.Principal == null) + { + return null; + } + + var userId = result.Principal.FindFirstValue(ClaimTypes.Name); + if (string.IsNullOrWhiteSpace(userId)) + { + return null; + } + + return await IdentityUserManager.FindSharedUserByIdAsync(userId); + } + + public override async Task TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberClient) + { + var user = await GetTwoFactorAuthenticationUserAsync(); + if (user == null) + { + return SignInResult.Failed; + } + + using (CurrentTenant.Change(user.TenantId)) + { + return await base.TwoFactorSignInAsync(provider, code, isPersistent, rememberClient); + } + } + + public override async Task TwoFactorRecoveryCodeSignInAsync(string recoveryCode) + { + var user = await GetTwoFactorAuthenticationUserAsync(); + if (user == null) + { + return SignInResult.Failed; + } + + using (CurrentTenant.Change(user.TenantId)) + { + // Base TwoFactorRecoveryCodeSignInAsync does not invoke PreSignInCheck, which means + // AbpSignInManager's IsActive / ShouldChangePassword checks would be bypassed. Run the + // same pre-sign-in checks here so recovery-code sign-in has the same gating as the + // regular two-factor sign-in path. + var preSignInCheckResult = await PreSignInCheck(user); + if (preSignInCheckResult != null) + { + return preSignInCheckResult; + } + + return await base.TwoFactorRecoveryCodeSignInAsync(recoveryCode); + } + } + /// /// This is to call the protection method PreSignInCheck /// 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 c0470eef80..46f7a635f0 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 @@ -745,21 +745,4 @@ public class IdentityUserManager : UserManager, IDomainService } } } - - public override async Task FindByIdAsync(string userId) - { - // In shared user sharing strategy, a user id is globally unique. Fall back to - // a cross-tenant lookup so callers that hit FindByIdAsync from a non-matching - // tenant context (notably the 2FA mid-flow invoked by ASP.NET Core Identity - // base SignInManager paths) can still resolve the user by id. - // Downstream operations on the returned entity should still be scoped to - // user.TenantId explicitly via CurrentTenant.Change. - var user = await base.FindByIdAsync(userId); - if (user != null || MultiTenancyOptions.Value.UserSharingStrategy == TenantUserSharingStrategy.Isolated) - { - return user; - } - - return await FindSharedUserByIdAsync(userId); - } } diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/GetTwoFactorAuthenticationUser_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/GetTwoFactorAuthenticationUser_Tests.cs index 267464c646..bbedc2c259 100644 --- a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/GetTwoFactorAuthenticationUser_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/GetTwoFactorAuthenticationUser_Tests.cs @@ -59,4 +59,87 @@ public class GetTwoFactorAuthenticationUser_Tests : SharedAbpIdentityAspNetCoreT content.ShouldBe("null"); } + + [Fact] + public async Task TwoFactorSignInAsync_Should_Resolve_Tenant_User_In_Shared_Mode() + { + // If the tenant switch inside AbpSignInManager.TwoFactorSignInAsync regresses, + // the base implementation would not find the user and return SignInResult.Failed. + // An inactive tenant user lets PreSignInCheck return NotAllowed, proving the user + // was actually located and inspected under its own tenant context. + var tenantUserId = await CreateInactiveTenantUserAsync("shared-2fa-signin", "shared-2fa-signin@abp.io"); + + await WriteTwoFactorCookieAsync(tenantUserId); + + var response = await Client.GetAsync("/api/signin-test/two-factor-signin?provider=Email&code=invalid"); + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadAsStringAsync(); + + result.ShouldBe("NotAllowed"); + } + + [Fact] + public async Task TwoFactorRecoveryCodeSignInAsync_Should_Resolve_Tenant_User_In_Shared_Mode() + { + // AbpSignInManager.TwoFactorRecoveryCodeSignInAsync runs PreSignInCheck after locating + // the user under its own tenant context. An inactive tenant user therefore produces + // NotAllowed only when the override actually resolves the user; a regression would + // return Failed instead. + var tenantUserId = await CreateInactiveTenantUserAsync("shared-2fa-recovery", "shared-2fa-recovery@abp.io"); + + await WriteTwoFactorCookieAsync(tenantUserId); + + var response = await Client.GetAsync("/api/signin-test/two-factor-recovery-signin?recoveryCode=invalid"); + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadAsStringAsync(); + + result.ShouldBe("NotAllowed"); + } + + private async Task CreateInactiveTenantUserAsync(string userName, string email) + { + return await CreateTenantUserAsync(userName, email, isActive: false); + } + + private async Task CreateTenantUserAsync(string userName, string email, bool isActive) + { + var userRepository = GetRequiredService(); + var currentTenant = GetRequiredService(); + var unitOfWorkManager = GetRequiredService(); + + var tenantId = Guid.NewGuid(); + Guid userId; + + using (var uow = unitOfWorkManager.Begin()) + { + using (currentTenant.Change(tenantId)) + { + var user = new IdentityUser(Guid.NewGuid(), userName, email, tenantId); + if (!isActive) + { + user.SetIsActive(false); + } + await userRepository.InsertAsync(user); + userId = user.Id; + } + await uow.CompleteAsync(); + } + + return userId; + } + + private async Task WriteTwoFactorCookieAsync(Guid userId) + { + var writeResponse = await Client.GetAsync($"/api/signin-test/write-two-factor-cookie?userId={userId}"); + writeResponse.EnsureSuccessStatusCode(); + + if (writeResponse.Headers.TryGetValues("Set-Cookie", out var setCookies)) + { + Client.DefaultRequestHeaders.Remove("Cookie"); + foreach (var cookie in setCookies) + { + Client.DefaultRequestHeaders.Add("Cookie", cookie.Split(';').First()); + } + } + } } diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SignInTestController.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SignInTestController.cs index fe77766cb3..8922915e6d 100644 --- a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SignInTestController.cs +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SignInTestController.cs @@ -45,4 +45,18 @@ public class SignInTestController : AbpController var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); return Content(user?.Id.ToString() ?? "null"); } + + [Route("two-factor-signin")] + public async Task TwoFactorSignIn(string provider, string code) + { + var result = await _signInManager.TwoFactorSignInAsync(provider, code, false, false); + return Content(result.ToString()); + } + + [Route("two-factor-recovery-signin")] + public async Task TwoFactorRecoverySignIn(string recoveryCode) + { + var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode); + return Content(result.ToString()); + } } 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 803bc258a3..1982080cf1 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 @@ -678,55 +678,6 @@ public class SharedTenantUserSharingStrategy_IdentityUserManager_Tests : AbpIden } } - [Fact] - public async Task FindByIdAsync_Should_Fall_Back_To_Cross_Tenant_Lookup_In_Shared_Mode() - { - // In shared user sharing strategy, UserManager.FindByIdAsync must resolve a - // tenant user even when CurrentTenant is host. This protects any caller that - // hits FindByIdAsync without an explicit shared-aware wrapper (e.g. ASP.NET - // Core Identity SignInManager internals for TwoFactorSignInAsync and - // TwoFactorRecoveryCodeSignInAsync). - var tenantId = Guid.NewGuid(); - IdentityUser tenantUser; - - using (var uow = _unitOfWorkManager.Begin()) - { - tenantUser = await CreateUserAsync(tenantId, "shared-findbyid-fallback", "shared-findbyid-fallback@abp.io"); - await uow.CompleteAsync(); - } - - using (_currentTenant.Change(null)) - { - var user = await _identityUserManager.FindByIdAsync(tenantUser.Id.ToString()); - - user.ShouldNotBeNull(); - user.Id.ShouldBe(tenantUser.Id); - user.TenantId.ShouldBe(tenantId); - } - } - - [Fact] - public async Task FindByIdAsync_Should_Return_Current_Tenant_Result_Without_Fallback_When_Found() - { - var tenantId = Guid.NewGuid(); - IdentityUser tenantUser; - - using (var uow = _unitOfWorkManager.Begin()) - { - tenantUser = await CreateUserAsync(tenantId, "shared-findbyid-hit", "shared-findbyid-hit@abp.io"); - await uow.CompleteAsync(); - } - - using (_currentTenant.Change(tenantId)) - { - var user = await _identityUserManager.FindByIdAsync(tenantUser.Id.ToString()); - - user.ShouldNotBeNull(); - user.Id.ShouldBe(tenantUser.Id); - user.TenantId.ShouldBe(tenantId); - } - } - [Fact] public async Task Login_Then_TwoFactor_MidFlow_Should_Resolve_Same_Tenant_User_In_Shared_Mode() { From ca9c2723f0f4005ae3659eef98c907677cad4293 Mon Sep 17 00:00:00 2001 From: maliming Date: Wed, 22 Apr 2026 12:56:08 +0800 Subject: [PATCH 06/13] Apply tenant identity options before base password sign-in Switching CurrentTenant to user.TenantId in PasswordSignInAsync without refreshing IdentityOptions meant that lockout, password policy and other tenant-scoped options used host values during the base sign-in call. Call IdentityOptions.SetAsync inside the tenant switch so downstream checks use the user's tenant configuration. --- .../Volo/Abp/Identity/AspNetCore/AbpSignInManager.cs | 4 ++++ 1 file changed, 4 insertions(+) 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 c8d3b5c275..eeaed97d31 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 @@ -17,6 +17,7 @@ public class AbpSignInManager : SignInManager protected ISettingProvider SettingProvider { get; } protected IdentityUserManager IdentityUserManager { get; } protected ICurrentTenant CurrentTenant { get; } + protected IOptions IdentityOptionsAccessor { get; } public AbpSignInManager( IdentityUserManager userManager, @@ -41,6 +42,7 @@ public class AbpSignInManager : SignInManager SettingProvider = settingProvider; IdentityUserManager = userManager; CurrentTenant = currentTenant; + IdentityOptionsAccessor = optionsAccessor; } public override async Task PasswordSignInAsync( @@ -86,6 +88,7 @@ public class AbpSignInManager : SignInManager using (CurrentTenant.Change(user.TenantId)) { + await IdentityOptionsAccessor.SetAsync(); return await SignInOrTwoFactorAsync(user, isPersistent); } } @@ -99,6 +102,7 @@ public class AbpSignInManager : SignInManager using (CurrentTenant.Change(user.TenantId)) { + await IdentityOptionsAccessor.SetAsync(); return await PasswordSignInAsync(user, password, isPersistent, lockoutOnFailure); } } From 8b48eb7f9896c99060c793b8d8b62039efc138d3 Mon Sep 17 00:00:00 2001 From: maliming Date: Wed, 22 Apr 2026 16:52:50 +0800 Subject: [PATCH 07/13] Add shared-user 2FA integration tests --- .../Identity/AspNetCore/AbpSignInManager.cs | 16 ++- .../AspNetCore/Isolated_TwoFactor_Tests.cs | 52 ++++++++ .../AspNetCore/Shared_SignIn_Tests.cs | 114 ++++++++++++++++++ .../AspNetCore/SignInTestController.cs | 7 ++ 4 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Isolated_TwoFactor_Tests.cs create mode 100644 modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Shared_SignIn_Tests.cs 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 eeaed97d31..34bf4e0c8d 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 @@ -115,12 +115,17 @@ public class AbpSignInManager : SignInManager return SignInResult.Failed; } - var error = await PreSignInCheck(user); - if (error != null) + using (CurrentTenant.Change(user.TenantId)) { - return error; + await IdentityOptionsAccessor.SetAsync(); + + var error = await PreSignInCheck(user); + if (error != null) + { + return error; + } + return await SignInOrTwoFactorAsync(user, isPersistent, loginProvider, bypassTwoFactor); } - return await SignInOrTwoFactorAsync(user, isPersistent, loginProvider, bypassTwoFactor); } public virtual async Task FindByEmailAsync(string email) @@ -165,6 +170,7 @@ public class AbpSignInManager : SignInManager using (CurrentTenant.Change(user.TenantId)) { + await IdentityOptionsAccessor.SetAsync(); return await base.TwoFactorSignInAsync(provider, code, isPersistent, rememberClient); } } @@ -179,6 +185,8 @@ public class AbpSignInManager : SignInManager using (CurrentTenant.Change(user.TenantId)) { + await IdentityOptionsAccessor.SetAsync(); + // Base TwoFactorRecoveryCodeSignInAsync does not invoke PreSignInCheck, which means // AbpSignInManager's IsActive / ShouldChangePassword checks would be bypassed. Run the // same pre-sign-in checks here so recovery-code sign-in has the same gating as the diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Isolated_TwoFactor_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Isolated_TwoFactor_Tests.cs new file mode 100644 index 0000000000..0ffcc2cf57 --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Isolated_TwoFactor_Tests.cs @@ -0,0 +1,52 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Uow; +using Xunit; + +namespace Volo.Abp.Identity.AspNetCore; + +public class Isolated_TwoFactor_Tests : AbpIdentityAspNetCoreTestBase +{ + [Fact] + public async Task TwoFactorRecoveryCodeSignInAsync_Should_Return_NotAllowed_For_Inactive_User() + { + // The AbpSignInManager override adds PreSignInCheck to the recovery-code path (the base + // AspNetCore Identity implementation does not). This test asserts that behavior also works + // in the default (isolated) configuration so the new invariant is protected across modes. + var userManager = GetRequiredService(); + var userRepository = GetRequiredService(); + var unitOfWorkManager = GetRequiredService(); + + Guid userId; + using (var uow = unitOfWorkManager.Begin()) + { + var user = new IdentityUser(Guid.NewGuid(), "iso-recovery-inactive", "iso-recovery-inactive@abp.io"); + (await userManager.CreateAsync(user, "Iso!9Aa")).Succeeded.ShouldBeTrue(); + user.SetIsActive(false); + await userRepository.UpdateAsync(user); + userId = user.Id; + await uow.CompleteAsync(); + } + + var writeResponse = await Client.GetAsync($"/api/signin-test/write-two-factor-cookie?userId={userId}"); + writeResponse.EnsureSuccessStatusCode(); + if (writeResponse.Headers.TryGetValues("Set-Cookie", out var setCookies)) + { + Client.DefaultRequestHeaders.Remove("Cookie"); + foreach (var cookie in setCookies) + { + Client.DefaultRequestHeaders.Add("Cookie", cookie.Split(';').First()); + } + } + + var response = await Client.GetAsync("/api/signin-test/two-factor-recovery-signin?recoveryCode=invalid"); + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadAsStringAsync(); + + result.ShouldBe("NotAllowed"); + } +} diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Shared_SignIn_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Shared_SignIn_Tests.cs new file mode 100644 index 0000000000..2f951345cc --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Shared_SignIn_Tests.cs @@ -0,0 +1,114 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Shouldly; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Uow; +using Xunit; + +namespace Volo.Abp.Identity.AspNetCore; + +public class Shared_SignIn_Tests : SharedAbpIdentityAspNetCoreTestBase +{ + [Fact] + public async Task PasswordSignInAsync_Should_Sign_In_Tenant_User_From_Host_Context() + { + // In shared mode, calling PasswordSignInAsync with a username while CurrentTenant is host + // must resolve the tenant user via FindSharedUserByNameAsync, apply the user's tenant + // IdentityOptions (the fix added in AbpSignInManager), and complete the sign-in. + var userManager = GetRequiredService(); + var currentTenant = GetRequiredService(); + var unitOfWorkManager = GetRequiredService(); + + var tenantId = Guid.NewGuid(); + const string userName = "shared-password-signin"; + const string password = "Shared!9Aa"; + + using (var uow = unitOfWorkManager.Begin()) + { + using (currentTenant.Change(tenantId)) + { + var user = new IdentityUser(Guid.NewGuid(), userName, userName + "@abp.io", tenantId); + (await userManager.CreateAsync(user, password)).Succeeded.ShouldBeTrue(); + } + await uow.CompleteAsync(); + } + + var response = await Client.GetAsync($"/api/signin-test/password?userName={userName}&password={password}"); + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadAsStringAsync(); + + result.ShouldBe("Succeeded"); + } + + [Fact] + public async Task PasswordSignInAsync_Should_Fail_For_Wrong_Password_In_Shared_Mode() + { + var userManager = GetRequiredService(); + var currentTenant = GetRequiredService(); + var unitOfWorkManager = GetRequiredService(); + + var tenantId = Guid.NewGuid(); + const string userName = "shared-password-wrong"; + + using (var uow = unitOfWorkManager.Begin()) + { + using (currentTenant.Change(tenantId)) + { + var user = new IdentityUser(Guid.NewGuid(), userName, userName + "@abp.io", tenantId); + (await userManager.CreateAsync(user, "Shared!9Aa")).Succeeded.ShouldBeTrue(); + } + await uow.CompleteAsync(); + } + + var response = await Client.GetAsync($"/api/signin-test/password?userName={userName}&password=wrong"); + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadAsStringAsync(); + + result.ShouldBe("Failed"); + } + + [Fact] + public async Task ExternalLoginSignInAsync_Should_Sign_In_Tenant_User_From_Host_Context() + { + // Covers the AbpSignInManager.ExternalLoginSignInAsync override: finds the user via + // FindSharedUserByLoginAsync, switches CurrentTenant to user.TenantId, applies tenant + // IdentityOptions, then calls PreSignInCheck + SignInOrTwoFactorAsync. + const string loginProvider = "test-provider"; + var providerKey = "ext-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var userManager = GetRequiredService(); + var currentTenant = GetRequiredService(); + var unitOfWorkManager = GetRequiredService(); + + var tenantId = Guid.NewGuid(); + + using (var uow = unitOfWorkManager.Begin()) + { + using (currentTenant.Change(tenantId)) + { + var user = new IdentityUser(Guid.NewGuid(), "shared-external-signin", "shared-external-signin@abp.io", tenantId); + (await userManager.CreateAsync(user, "Shared!9Aa")).Succeeded.ShouldBeTrue(); + (await userManager.AddLoginAsync(user, new UserLoginInfo(loginProvider, providerKey, "Test Provider"))).Succeeded.ShouldBeTrue(); + } + await uow.CompleteAsync(); + } + + var response = await Client.GetAsync($"/api/signin-test/external-login-signin?loginProvider={loginProvider}&providerKey={providerKey}"); + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadAsStringAsync(); + + result.ShouldBe("Succeeded"); + } + + [Fact] + public async Task ExternalLoginSignInAsync_Should_Fail_For_Unknown_Provider_Key_In_Shared_Mode() + { + var response = await Client.GetAsync($"/api/signin-test/external-login-signin?loginProvider=unknown&providerKey=none"); + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadAsStringAsync(); + + result.ShouldBe("Failed"); + } +} diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SignInTestController.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SignInTestController.cs index 8922915e6d..b2381a6a53 100644 --- a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SignInTestController.cs +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SignInTestController.cs @@ -59,4 +59,11 @@ public class SignInTestController : AbpController var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode); return Content(result.ToString()); } + + [Route("external-login-signin")] + public async Task ExternalLoginSignIn(string loginProvider, string providerKey) + { + var result = await _signInManager.ExternalLoginSignInAsync(loginProvider, providerKey, false, false); + return Content(result.ToString()); + } } From 5c883f373d4ec9cb889271166f26a564137897cc Mon Sep 17 00:00:00 2001 From: Ma Liming Date: Wed, 22 Apr 2026 17:03:03 +0800 Subject: [PATCH 08/13] Update modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Isolated_TwoFactor_Tests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Volo/Abp/Identity/AspNetCore/Isolated_TwoFactor_Tests.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Isolated_TwoFactor_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Isolated_TwoFactor_Tests.cs index 0ffcc2cf57..1fb730538e 100644 --- a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Isolated_TwoFactor_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Isolated_TwoFactor_Tests.cs @@ -2,8 +2,6 @@ using System; using System.Linq; using System.Threading.Tasks; using Shouldly; -using Volo.Abp.Domain.Repositories; -using Volo.Abp.MultiTenancy; using Volo.Abp.Uow; using Xunit; From 1b2906fdec3c6c6324f6e74312b3d3a9669a6c85 Mon Sep 17 00:00:00 2001 From: Ma Liming Date: Wed, 22 Apr 2026 17:03:09 +0800 Subject: [PATCH 09/13] Update modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Shared_SignIn_Tests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Volo/Abp/Identity/AspNetCore/Shared_SignIn_Tests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Shared_SignIn_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Shared_SignIn_Tests.cs index 2f951345cc..2fa0d30743 100644 --- a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Shared_SignIn_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Shared_SignIn_Tests.cs @@ -2,7 +2,6 @@ using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Shouldly; -using Volo.Abp.Domain.Repositories; using Volo.Abp.MultiTenancy; using Volo.Abp.Uow; using Xunit; From 41a0b1236f247f1ee7a84b5f6c191e03f8bf936b Mon Sep 17 00:00:00 2001 From: Ma Liming Date: Wed, 22 Apr 2026 17:03:16 +0800 Subject: [PATCH 10/13] Update modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/GetTwoFactorAuthenticationUser_Tests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Identity/AspNetCore/GetTwoFactorAuthenticationUser_Tests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/GetTwoFactorAuthenticationUser_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/GetTwoFactorAuthenticationUser_Tests.cs index bbedc2c259..6bdb71a12a 100644 --- a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/GetTwoFactorAuthenticationUser_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/GetTwoFactorAuthenticationUser_Tests.cs @@ -2,7 +2,6 @@ using System; using System.Linq; using System.Threading.Tasks; using Shouldly; -using Volo.Abp.Domain.Repositories; using Volo.Abp.MultiTenancy; using Volo.Abp.Uow; using Xunit; From a613e8899c7096f2a17c0e516eda750e0c0b76b9 Mon Sep 17 00:00:00 2001 From: maliming Date: Wed, 22 Apr 2026 17:26:39 +0800 Subject: [PATCH 11/13] Reuse two-factor cookie helper in shared test --- .../GetTwoFactorAuthenticationUser_Tests.cs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/GetTwoFactorAuthenticationUser_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/GetTwoFactorAuthenticationUser_Tests.cs index 6bdb71a12a..3ce774ff81 100644 --- a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/GetTwoFactorAuthenticationUser_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/GetTwoFactorAuthenticationUser_Tests.cs @@ -31,16 +31,7 @@ public class GetTwoFactorAuthenticationUser_Tests : SharedAbpIdentityAspNetCoreT await uow.CompleteAsync(); } - var writeResponse = await Client.GetAsync($"/api/signin-test/write-two-factor-cookie?userId={tenantUserId}"); - writeResponse.EnsureSuccessStatusCode(); - - if (writeResponse.Headers.TryGetValues("Set-Cookie", out var setCookies)) - { - foreach (var cookie in setCookies) - { - Client.DefaultRequestHeaders.Add("Cookie", cookie.Split(';').First()); - } - } + await WriteTwoFactorCookieAsync(tenantUserId); var getResponse = await Client.GetAsync("/api/signin-test/get-two-factor-user"); getResponse.EnsureSuccessStatusCode(); From 37a0972595ddc6cbf014d1d05fd68f4447405f68 Mon Sep 17 00:00:00 2001 From: maliming Date: Wed, 22 Apr 2026 18:15:42 +0800 Subject: [PATCH 12/13] Document host-only vs tenant-admin operations in shared mode --- .../modules/account/shared-user-accounts.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/en/modules/account/shared-user-accounts.md b/docs/en/modules/account/shared-user-accounts.md index 4df18e2fdf..6f028238c6 100644 --- a/docs/en/modules/account/shared-user-accounts.md +++ b/docs/en/modules/account/shared-user-accounts.md @@ -142,6 +142,30 @@ Configure(options => ![new-user--join-strategy-inform](../../images/new-user-join-strategy-inform.png) +## Tenant Admin vs Host Admin Operations + +When the Shared strategy is enabled, a user is a **global resource** across host and all tenants. Their identity, activation state, lockout, password policy and two-factor settings live at the host level. Therefore some user management operations are restricted to host administrators and are not available to tenant administrators. + +### Host-only operations + +The following operations can only be performed by a host administrator when Shared is enabled. Both the Identity Pro UI (MVC + Blazor) and the `IdentityUserAppService` enforce this — a direct API call from a tenant context will be rejected with a `UserFriendlyException`: + +- Delete a user +- Activate / deactivate a user (`IsActive`) +- Lock / unlock a user +- Enable or disable two-factor authentication +- Change `LockoutEnabled` or `ShouldChangePasswordOnNextLogin` + +### What tenant admins can do + +- Invite users to the tenant (see the Invitation flow above) +- Manage role and organization-unit assignments within the tenant +- View audit / security logs scoped to the tenant + +### What a user can do for themselves + +Users can leave a tenant from their own account menu (`Switch Tenant` → `Leave`). Leaving marks the tenant membership as `Leaved = true` and preserves the user's host identity, so they can be re-invited later with the same `UserId`. + ## 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: From 212811d97b514547c0095e805e091e1a768d1a6b Mon Sep 17 00:00:00 2001 From: maliming Date: Thu, 23 Apr 2026 10:23:48 +0800 Subject: [PATCH 13/13] Add update and host-tenant uniqueness tests for AbpIdentityUserValidator --- .../AbpIdentityUserValidator_Tests.cs | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) 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 752bc3496a..b1410b2068 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 @@ -190,4 +190,90 @@ public class AbpIdentityUserValidator_SharedUser_Tests : AbpIdentityAspNetCoreTe result.Errors.First().Code.ShouldBe("InvalidEmail"); } } + + [Fact] + public async Task Should_Allow_Update_Without_UserName_Or_Email_Changes() + { + var tenantId = Guid.NewGuid(); + + using (_currentTenant.Change(tenantId)) + { + var user = new IdentityUser(Guid.NewGuid(), "unchanged-user", "unchanged@volosoft.com") { Name = "Original" }; + (await _identityUserManager.CreateAsync(user)).Succeeded.ShouldBeTrue(); + + user.Name = "Changed"; + (await _identityUserManager.UpdateAsync(user)).Succeeded.ShouldBeTrue(); + } + } + + [Fact] + public async Task Should_Allow_Update_Changing_UserName_To_A_Globally_Unique_Value() + { + var tenantId = Guid.NewGuid(); + + using (_currentTenant.Change(tenantId)) + { + var user = new IdentityUser(Guid.NewGuid(), "rename-start", "rename@volosoft.com"); + (await _identityUserManager.CreateAsync(user)).Succeeded.ShouldBeTrue(); + + var result = await _identityUserManager.SetUserNameAsync(user, "rename-end"); + result.Succeeded.ShouldBeTrue(); + } + } + + [Fact] + public async Task Should_Allow_Update_Changing_Email_To_A_Globally_Unique_Value() + { + var tenantId = Guid.NewGuid(); + + using (_currentTenant.Change(tenantId)) + { + var user = new IdentityUser(Guid.NewGuid(), "email-change", "email-before@volosoft.com"); + (await _identityUserManager.CreateAsync(user)).Succeeded.ShouldBeTrue(); + + var result = await _identityUserManager.SetEmailAsync(user, "email-after@volosoft.com"); + result.Succeeded.ShouldBeTrue(); + } + } + + // Host-user scenarios (TenantId == null): still must enforce global uniqueness on Create. + [Fact] + public async Task Should_Reject_Duplicate_UserName_Between_Host_User_And_Tenant_User() + { + var tenantId = Guid.NewGuid(); + const string sharedName = "host-vs-tenant-name"; + + // Host user first. + var hostUser = new IdentityUser(Guid.NewGuid(), sharedName, "host-side@volosoft.com"); + (await _identityUserManager.CreateAsync(hostUser)).Succeeded.ShouldBeTrue(); + + using (_currentTenant.Change(tenantId)) + { + var tenantUser = new IdentityUser(Guid.NewGuid(), sharedName, "tenant-side@volosoft.com"); + var result = await _identityUserManager.CreateAsync(tenantUser); + + result.Succeeded.ShouldBeFalse(); + result.Errors.Any(e => e.Code == "DuplicateUserName").ShouldBeTrue(); + } + } + + [Fact] + public async Task Should_Reject_Duplicate_Email_Between_Host_User_And_Tenant_User() + { + var tenantId = Guid.NewGuid(); + const string sharedEmail = "host-vs-tenant-email@volosoft.com"; + + var hostUser = new IdentityUser(Guid.NewGuid(), "host-email-user", sharedEmail); + (await _identityUserManager.CreateAsync(hostUser)).Succeeded.ShouldBeTrue(); + + using (_currentTenant.Change(tenantId)) + { + var tenantUser = new IdentityUser(Guid.NewGuid(), "tenant-email-user", sharedEmail); + var result = await _identityUserManager.CreateAsync(tenantUser); + + result.Succeeded.ShouldBeFalse(); + result.Errors.Any(e => e.Code == "DuplicateEmail").ShouldBeTrue(); + } + } + }