Browse Source

Merge pull request #25318 from abpframework/auto-merge/rel-10-3/4518

Merge branch dev with rel-10.3
pull/25328/head
Volosoft Agent 2 weeks ago
committed by GitHub
parent
commit
3da8c01d61
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 24
      docs/en/modules/account/shared-user-accounts.md
  2. 78
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSignInManager.cs
  3. 27
      modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs
  4. 86
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpIdentityUserValidator_Tests.cs
  5. 135
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/GetTwoFactorAuthenticationUser_Tests.cs
  6. 50
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Isolated_TwoFactor_Tests.cs
  7. 7
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SharedAbpIdentityAspNetCoreTestBase.cs
  8. 17
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SharedAbpIdentityAspNetCoreTestModule.cs
  9. 18
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SharedAbpIdentityAspNetCoreTestStartup.cs
  10. 113
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Shared_SignIn_Tests.cs
  11. 41
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SignInTestController.cs
  12. 88
      modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserManager_Tests.cs

24
docs/en/modules/account/shared-user-accounts.md

@ -142,6 +142,30 @@ Configure<AbpIdentityPendingTenantUserOptions>(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:

78
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;
@ -16,6 +17,7 @@ public class AbpSignInManager : SignInManager<IdentityUser>
protected ISettingProvider SettingProvider { get; }
protected IdentityUserManager IdentityUserManager { get; }
protected ICurrentTenant CurrentTenant { get; }
protected IOptions<IdentityOptions> IdentityOptionsAccessor { get; }
public AbpSignInManager(
IdentityUserManager userManager,
@ -40,6 +42,7 @@ public class AbpSignInManager : SignInManager<IdentityUser>
SettingProvider = settingProvider;
IdentityUserManager = userManager;
CurrentTenant = currentTenant;
IdentityOptionsAccessor = optionsAccessor;
}
public override async Task<SignInResult> PasswordSignInAsync(
@ -85,6 +88,7 @@ public class AbpSignInManager : SignInManager<IdentityUser>
using (CurrentTenant.Change(user.TenantId))
{
await IdentityOptionsAccessor.SetAsync();
return await SignInOrTwoFactorAsync(user, isPersistent);
}
}
@ -98,6 +102,7 @@ public class AbpSignInManager : SignInManager<IdentityUser>
using (CurrentTenant.Change(user.TenantId))
{
await IdentityOptionsAccessor.SetAsync();
return await PasswordSignInAsync(user, password, isPersistent, lockoutOnFailure);
}
}
@ -110,12 +115,17 @@ public class AbpSignInManager : SignInManager<IdentityUser>
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<IdentityUser> FindByEmailAsync(string email)
@ -133,6 +143,64 @@ public class AbpSignInManager : SignInManager<IdentityUser>
return await IdentityUserManager.FindSharedUserByLoginAsync(loginProvider, providerKey);
}
public override async Task<IdentityUser> 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<SignInResult> TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberClient)
{
var user = await GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
return SignInResult.Failed;
}
using (CurrentTenant.Change(user.TenantId))
{
await IdentityOptionsAccessor.SetAsync();
return await base.TwoFactorSignInAsync(provider, code, isPersistent, rememberClient);
}
}
public override async Task<SignInResult> TwoFactorRecoveryCodeSignInAsync(string recoveryCode)
{
var user = await GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
return SignInResult.Failed;
}
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
// regular two-factor sign-in path.
var preSignInCheckResult = await PreSignInCheck(user);
if (preSignInCheckResult != null)
{
return preSignInCheckResult;
}
return await base.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
}
}
/// <summary>
/// This is to call the protection method PreSignInCheck
/// </summary>

27
modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs

@ -718,4 +718,31 @@ public class IdentityUserManager : UserManager<IdentityUser>, IDomainService
}
}
public virtual async Task<IdentityUser> FindSharedUserByIdAsync(string userId)
{
if (MultiTenancyOptions.Value.UserSharingStrategy == TenantUserSharingStrategy.Isolated)
{
return await base.FindByIdAsync(userId);
}
using (CurrentTenant.Change(null))
{
using (DataFilter.Disable<IMultiTenant>())
{
var user = await base.FindByIdAsync(userId);
if (user == null)
{
return null;
}
using (DataFilter.Enable<IMultiTenant>())
{
using (CurrentTenant.Change(user.TenantId))
{
return await base.FindByIdAsync(userId);
}
}
}
}
}
}

86
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();
}
}
}

135
modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/GetTwoFactorAuthenticationUser_Tests.cs

@ -0,0 +1,135 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Shouldly;
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<IIdentityUserRepository>();
var currentTenant = GetRequiredService<ICurrentTenant>();
var unitOfWorkManager = GetRequiredService<IUnitOfWorkManager>();
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();
}
await WriteTwoFactorCookieAsync(tenantUserId);
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");
}
[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<Guid> CreateInactiveTenantUserAsync(string userName, string email)
{
return await CreateTenantUserAsync(userName, email, isActive: false);
}
private async Task<Guid> CreateTenantUserAsync(string userName, string email, bool isActive)
{
var userRepository = GetRequiredService<IIdentityUserRepository>();
var currentTenant = GetRequiredService<ICurrentTenant>();
var unitOfWorkManager = GetRequiredService<IUnitOfWorkManager>();
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());
}
}
}
}

50
modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Isolated_TwoFactor_Tests.cs

@ -0,0 +1,50 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Shouldly;
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<IdentityUserManager>();
var userRepository = GetRequiredService<IIdentityUserRepository>();
var unitOfWorkManager = GetRequiredService<IUnitOfWorkManager>();
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");
}
}

7
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<SharedAbpIdentityAspNetCoreTestStartup>
{
}

17
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<AbpMultiTenancyOptions>(options =>
{
options.IsEnabled = true;
options.UserSharingStrategy = TenantUserSharingStrategy.Shared;
});
}
}

18
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<SharedAbpIdentityAspNetCoreTestModule>();
}
public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory)
{
app.InitializeApplication();
}
}

113
modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/Shared_SignIn_Tests.cs

@ -0,0 +1,113 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Shouldly;
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<IdentityUserManager>();
var currentTenant = GetRequiredService<ICurrentTenant>();
var unitOfWorkManager = GetRequiredService<IUnitOfWorkManager>();
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<IdentityUserManager>();
var currentTenant = GetRequiredService<ICurrentTenant>();
var unitOfWorkManager = GetRequiredService<IUnitOfWorkManager>();
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<IdentityUserManager>();
var currentTenant = GetRequiredService<ICurrentTenant>();
var unitOfWorkManager = GetRequiredService<IUnitOfWorkManager>();
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");
}
}

41
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,41 @@ public class SignInTestController : AbpController
return Content(result.ToString());
}
[Route("write-two-factor-cookie")]
public async Task<ActionResult> 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<ActionResult> GetTwoFactorUser()
{
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
return Content(user?.Id.ToString() ?? "null");
}
[Route("two-factor-signin")]
public async Task<ActionResult> 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<ActionResult> TwoFactorRecoverySignIn(string recoveryCode)
{
var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
return Content(result.ToString());
}
[Route("external-login-signin")]
public async Task<ActionResult> ExternalLoginSignIn(string loginProvider, string providerKey)
{
var result = await _signInManager.ExternalLoginSignInAsync(loginProvider, providerKey, false, false);
return Content(result.ToString());
}
}

88
modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserManager_Tests.cs

@ -618,6 +618,94 @@ 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();
}
}
[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<IdentityUser> CreateUserAsync(
Guid? tenantId,
string userName,

Loading…
Cancel
Save