From 22bb2fee57083dd51503768a0ae648046e6d5834 Mon Sep 17 00:00:00 2001 From: maliming Date: Mon, 31 Aug 2026 16:31:10 +0800 Subject: [PATCH] Look up single-active token providers through IdentityUserManager - The public ProviderType is the last type registered under a key, not the one the manager uses - Add cross-host coverage for a second user type and for both hosts opting out --- docs/en/modules/identity/token-providers.md | 2 +- .../release-info/migration-guides/abp-10-7.md | 2 +- ...yUserManagerSingleActiveTokenExtensions.cs | 11 +-- .../Volo/Abp/Identity/IdentityUserManager.cs | 22 ++++- ...kenProvider_PayloadCompatibility_Tests.cs} | 4 +- .../AspNetCore/TokenProviderLifespan_Tests.cs | 11 ++- .../CrossHostTokenProvider_Tests.cs | 99 +++++++++++++++++++ 7 files changed, 134 insertions(+), 17 deletions(-) rename modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/{AbpSingleActiveTokenProvider_Compatibility_Tests.cs => AbpSingleActiveTokenProvider_PayloadCompatibility_Tests.cs} (95%) diff --git a/docs/en/modules/identity/token-providers.md b/docs/en/modules/identity/token-providers.md index 7ffed9f99a..a032fd6beb 100644 --- a/docs/en/modules/identity/token-providers.md +++ b/docs/en/modules/identity/token-providers.md @@ -110,7 +110,7 @@ await UserManager.RemoveLinkUserTokenAsync(user); await UserManager.RemoveLinkUserTokenAsync(user, customPurpose); ``` -Each method removes the stored hash under `"[AbpSingleActiveToken]"` for the corresponding purpose. Validation afterwards returns `false` even if the token blob itself is still within its DataProtector lifespan and the `SecurityStamp` is unchanged. They throw an `AbpException` when they cannot see an `AbpSingleActiveTokenProvider` on the key: with the ABP providers turned off there is no stored hash to remove, and a token that was never single-active cannot be revoked this way. A key that also carries a provider registered for a second user type reads as the same case, because the one `IdentityUserManager` picks out of it is not visible to these helpers. +Each method removes the stored hash under `"[AbpSingleActiveToken]"` for the corresponding purpose. Validation afterwards returns `false` even if the token blob itself is still within its DataProtector lifespan and the `SecurityStamp` is unchanged. They throw an `AbpException` when the key is not served by an `AbpSingleActiveTokenProvider`: with the ABP providers turned off there is no stored hash to remove, and a token that was never single-active cannot be revoked this way. For tokens issued by `AbpDefaultTokenProvider` (e.g. `RequiresTwoFactor`, `ShouldChangePasswordOnNextLogin`, `PeriodicallyChangePassword`), call `UserManager.RemoveAuthenticationTokenAsync` directly. The name is built from the provider's options `Name`, which is `TokenOptions.DefaultProvider` unless you changed it: diff --git a/docs/en/release-info/migration-guides/abp-10-7.md b/docs/en/release-info/migration-guides/abp-10-7.md index eb2f019d93..9b586204e1 100644 --- a/docs/en/release-info/migration-guides/abp-10-7.md +++ b/docs/en/release-info/migration-guides/abp-10-7.md @@ -149,7 +149,7 @@ Add a navigation property to the principal entity if you rely on the previous be - Every host that loads `AbpIdentityDomainModule` therefore resolves the same providers, unless it registers something else itself. Previously the providers were only registered on hosts loading `AbpIdentityAspNetCoreModule`, so a host that generated a token could end up on a different provider than the host that validated it, and the link was rejected as an invalid token. - `AbpSingleActiveTokenProvider` no longer derives from ASP.NET Core's `DataProtectorTokenProvider`; it implements `IUserTwoFactorTokenProvider` and re-implements the same protected payload, so the token format is unchanged. Code that casts a provider to `DataProtectorTokenProvider` or uses it as a generic constraint no longer compiles, and the `Logger` property the old base class exposed publicly is now protected. - The provider options classes derive from `AbpDataProtectionTokenProviderOptions` instead of `DataProtectionTokenProviderOptions`. The `Name` and `TokenLifespan` properties are unchanged, but code that assigns one of them to `DataProtectionTokenProviderOptions`, passes it to a method taking that type, returns it, or uses it as a generic constraint no longer compiles. -- `IdentityUserManagerSingleActiveTokenExtensions` moved with the providers, and its `Remove*TokenAsync` helpers changed in two ways. They now follow the provider's options `Name` instead of the key it is registered under, so an application that renamed a provider gets the hash it actually wrote removed. And they throw an `AbpException` instead of reporting success when they cannot see an `AbpSingleActiveTokenProvider` on the key. That is the case once the ABP providers are turned off, where there is no stored hash to remove and a token that was never single-active cannot be revoked this way. It is also the case when the key carries a provider for a second user type, which `IdentityUserManager` skips and these helpers cannot: they read the key's public `ProviderType`, while the one the manager picks is only reachable through an internal ASP.NET Core API. +- `IdentityUserManagerSingleActiveTokenExtensions` moved with the providers, and its `Remove*TokenAsync` helpers changed in two ways. They now follow the provider's options `Name` instead of the key it is registered under, so an application that renamed a provider gets the hash it actually wrote removed. And they throw an `AbpException` instead of reporting success when the key is not served by an `AbpSingleActiveTokenProvider`, which is the case once the ABP providers are turned off: there is no stored hash to remove then, and a token that was never single-active cannot be revoked this way. - The constructors changed accordingly. `AbpSingleActiveTokenProvider` takes `IOptions`, which each provider satisfies with its own concrete options class, and `ILogger` instead of `IOptions` and `ILogger>`. The five DataProtector-based providers (`AbpDefaultTokenProvider`, `AbpPasswordResetTokenProvider`, `AbpEmailConfirmationTokenProvider`, `AbpChangeEmailTokenProvider`, `LinkUserTokenProvider`) take the new logger type as well. The email and phone 2FA providers moved unchanged. Constructing a provider by hand also behaves differently at the edges: a null options now throws instead of falling back to the ASP.NET Core defaults, which carry the wrong provider name, and a null logger falls back to `NullLogger` instead of throwing. - `AbpIdentityDomainModule` calls `AddDataProtection()`, because `UserManager` instantiates every provider in `Tokens.ProviderMap` when it is resolved and the DataProtector-based providers need `IDataProtectionProvider`. Hosts that never issue a token, such as a DbMigrator console application, do not register it themselves, and now load the key ring on startup and create one if the store is empty. That is a side effect for such a host, not a reason to configure it: only a host that generates or validates a token needs the same key ring and `SetApplicationName` as the rest of the solution. diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions.cs index 7186d5e883..e8caab81ec 100644 --- a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions.cs +++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; @@ -89,20 +88,14 @@ public static class IdentityUserManagerSingleActiveTokenExtensions /// private static string GetStoredTokenName(IdentityUserManager manager, string providerKey, string purpose) { - var descriptor = manager.Options.Tokens.ProviderMap.GetOrDefault(providerKey); - var provider = descriptor?.ProviderInstance ?? (descriptor != null - ? manager.ServiceProvider.GetService(descriptor.ProviderType) - : null); - - if (provider is not AbpSingleActiveTokenProvider singleActiveTokenProvider) + if (manager.FindTokenProvider(providerKey) is not AbpSingleActiveTokenProvider singleActiveTokenProvider) { throw new AbpException( $"The '{providerKey}' token provider is not an {nameof(AbpSingleActiveTokenProvider)}, so it does not " + $"store a token hash that can be removed. This happens when the key has no provider at all, when " + $"the ABP token providers are turned off " + $"through {nameof(AbpIdentityTokenProviderOptions)}.{nameof(AbpIdentityTokenProviderOptions.UseAbpTokenProviders)} " + - $"or when the key was re-registered with another provider, including one registered for a " + - $"second user type."); + $"or when the key was re-registered with another provider."); } return singleActiveTokenProvider.Name + ":" + purpose; 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 8e327439af..2ea747e868 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 @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -24,6 +24,12 @@ namespace Volo.Abp.Identity; public class IdentityUserManager : UserManager, IDomainService { + /// + /// The base class keeps its own map private, and the provider it picks for a token provider key is + /// only reachable through an internal API when the key carries providers for more than one user type. + /// + private readonly Dictionary> _registeredTokenProviders = new(); + protected IIdentityRoleRepository RoleRepository { get; } protected IIdentityUserRepository UserRepository { get; } protected IOrganizationUnitRepository OrganizationUnitRepository { get; } @@ -85,6 +91,20 @@ public class IdentityUserManager : UserManager, IDomainService CancellationTokenProvider = cancellationTokenProvider; } + public override void RegisterTokenProvider(string providerName, IUserTwoFactorTokenProvider provider) + { + base.RegisterTokenProvider(providerName, provider); + _registeredTokenProviders[providerName] = provider; + } + + /// + /// The token provider this manager uses for , or null when the key has none. + /// + public virtual IUserTwoFactorTokenProvider? FindTokenProvider(string providerName) + { + return _registeredTokenProviders.GetOrDefault(providerName); + } + public virtual async Task CreateAsync(IdentityUser user, string password, bool validatePassword) { var result = await UpdatePasswordHash(user, password, validatePassword); diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider_Compatibility_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider_PayloadCompatibility_Tests.cs similarity index 95% rename from modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider_Compatibility_Tests.cs rename to modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider_PayloadCompatibility_Tests.cs index 0d750b8e2a..bdba8dd624 100644 --- a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider_Compatibility_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider_PayloadCompatibility_Tests.cs @@ -17,7 +17,7 @@ namespace Volo.Abp.Identity.AspNetCore; /// , which it re-implements rather than derives from: /// a token produced by one has to be accepted by the other under the same provider name. /// -public class AbpSingleActiveTokenProvider_Compatibility_Tests : AbpIdentityAspNetCoreTestBase +public class AbpSingleActiveTokenProvider_PayloadCompatibility_Tests : AbpIdentityAspNetCoreTestBase { private const string Purpose = "ResetPassword"; @@ -26,7 +26,7 @@ public class AbpSingleActiveTokenProvider_Compatibility_Tests : AbpIdentityAspNe private readonly IdentityTestData _testData; private readonly IUnitOfWorkManager _unitOfWorkManager; - public AbpSingleActiveTokenProvider_Compatibility_Tests() + public AbpSingleActiveTokenProvider_PayloadCompatibility_Tests() { _userRepository = GetRequiredService(); _userManager = GetRequiredService(); diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/TokenProviderLifespan_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/TokenProviderLifespan_Tests.cs index 860e158c77..f9f99cfa2c 100644 --- a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/TokenProviderLifespan_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/TokenProviderLifespan_Tests.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Shouldly; @@ -35,9 +36,13 @@ public class TokenProviderLifespan_Tests [Fact] public void The_Shared_Default_Should_Match_The_AspNetCore_One() { - // A provider that does not set its own lifespan has to land on the ASP.NET Core defaults. - new UnconfiguredTokenProviderOptions().Name.ShouldBe("DataProtectorTokenProvider"); - new UnconfiguredTokenProviderOptions().TokenLifespan.ShouldBe(TimeSpan.FromDays(1)); + // Compared against the live ASP.NET Core defaults, so that a change on their side shows up here + // instead of silently moving every provider that does not set its own lifespan. + var aspNetCore = new DataProtectionTokenProviderOptions(); + var abp = new UnconfiguredTokenProviderOptions(); + + abp.Name.ShouldBe(aspNetCore.Name); + abp.TokenLifespan.ShouldBe(aspNetCore.TokenLifespan); } private static TimeSpan LifespanOf(IAbpApplication application) diff --git a/modules/identity/test/Volo.Abp.Identity.EntityFrameworkCore.Tests/Volo/Abp/Identity/EntityFrameworkCore/CrossHostTokenProvider_Tests.cs b/modules/identity/test/Volo.Abp.Identity.EntityFrameworkCore.Tests/Volo/Abp/Identity/EntityFrameworkCore/CrossHostTokenProvider_Tests.cs index c202cac733..31a9ed7e07 100644 --- a/modules/identity/test/Volo.Abp.Identity.EntityFrameworkCore.Tests/Volo/Abp/Identity/EntityFrameworkCore/CrossHostTokenProvider_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.EntityFrameworkCore.Tests/Volo/Abp/Identity/EntityFrameworkCore/CrossHostTokenProvider_Tests.cs @@ -147,6 +147,44 @@ public class CrossHostTokenProvider_Tests } } + [Fact] + public async Task A_Password_Reset_Should_Complete_Across_Two_Hosts_That_Both_Opted_Out() + { + // The escape hatch the documentation offers: turning the ABP providers off and registering the + // ASP.NET Core ones has to leave a working flow, as long as both ends do it. + var userId = await CreateUserAsync(); + + using var generator = await CreateHostAsync(); + using var validator = await CreateHostAsync(); + try + { + var token = await WithUowAsync(generator, async sp => + { + var userManager = sp.GetRequiredService(); + var identityOptions = sp.GetRequiredService>().Value; + identityOptions.Tokens.ProviderMap[identityOptions.Tokens.PasswordResetTokenProvider].ProviderType + .ShouldBe(typeof(DataProtectorTokenProvider)); + return await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId)); + }); + + var result = await WithUowAsync(validator, async sp => + { + var userManager = sp.GetRequiredService(); + var identityOptions = sp.GetRequiredService>().Value; + identityOptions.Tokens.ProviderMap[identityOptions.Tokens.PasswordResetTokenProvider].ProviderType + .ShouldBe(typeof(DataProtectorTokenProvider)); + return await userManager.ResetPasswordAsync(await userManager.GetByIdAsync(userId), token, "1q2w3E*OPTOUT"); + }); + + result.Succeeded.ShouldBeTrue(); + } + finally + { + await validator.ShutdownAsync(); + await generator.ShutdownAsync(); + } + } + [Fact] public async Task Removing_A_Stored_Token_Should_Follow_A_Customized_Provider_Name() { @@ -214,6 +252,39 @@ public class CrossHostTokenProvider_Tests } } + [Fact] + public async Task Removing_A_Stored_Token_Should_Ignore_A_Provider_Registered_For_Another_User_Type() + { + // IdentityUserManager skips a provider that does not serve IdentityUser, so the helpers have to + // skip it too. The key's public ProviderType is the last type registered under it, which is the + // other user type's provider here. + var userId = await CreateUserAsync(); + + using var secondUserTypeHost = await CreateHostAsync(); + try + { + await WithUowAsync(secondUserTypeHost, async sp => + { + var identityOptions = sp.GetRequiredService>().Value; + identityOptions.Tokens.ProviderMap[AbpPasswordResetTokenProvider.ProviderName].ProviderType + .ShouldBe(typeof(OtherUserTokenProvider)); + + var userManager = sp.GetRequiredService(); + var user = await userManager.GetByIdAsync(userId); + + await userManager.GeneratePasswordResetTokenAsync(user); + (await userManager.RemovePasswordResetTokenAsync(await userManager.GetByIdAsync(userId))) + .Succeeded.ShouldBeTrue(); + + return true; + }); + } + finally + { + await secondUserTypeHost.ShutdownAsync(); + } + } + [Fact] public async Task The_Validating_Host_Should_Decide_Whether_A_Token_Expired() { @@ -372,6 +443,34 @@ public class StockProviderGeneratorHostModule : EfCoreCrossHostTestModuleBase } } +/// A host where a second Identity user type registered a provider under an ABP key. The descriptor +/// hands that one out, while the manager keeps using the ABP provider. +[DependsOn(typeof(AbpAutofacModule), typeof(AbpIdentityEntityFrameworkCoreModule), typeof(AbpEntityFrameworkCoreSqliteModule))] +public class SecondUserTypeHostModule : EfCoreCrossHostTestModuleBase +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + base.ConfigureServices(context); + + // After the framework registration, so this one ends up on top of the key's provider stack. + new IdentityBuilder(typeof(OtherUser), context.Services) + .AddTokenProvider(AbpPasswordResetTokenProvider.ProviderName); + } +} + +public class OtherUser +{ +} + +public class OtherUserTokenProvider : IUserTwoFactorTokenProvider +{ + public Task CanGenerateTwoFactorTokenAsync(UserManager manager, OtherUser user) => Task.FromResult(false); + + public Task GenerateAsync(string purpose, UserManager manager, OtherUser user) => Task.FromResult(string.Empty); + + public Task ValidateAsync(string purpose, string token, UserManager manager, OtherUser user) => Task.FromResult(false); +} + /// A host that renamed the password-reset provider. The stored hash then lives under that name and /// no longer matches the key the provider is registered under. [DependsOn(typeof(AbpAutofacModule), typeof(AbpIdentityEntityFrameworkCoreModule), typeof(AbpEntityFrameworkCoreSqliteModule))]