Browse Source

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
pull/26113/head
maliming 4 days ago
parent
commit
22bb2fee57
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 2
      docs/en/modules/identity/token-providers.md
  2. 2
      docs/en/release-info/migration-guides/abp-10-7.md
  3. 11
      modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions.cs
  4. 22
      modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs
  5. 4
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider_PayloadCompatibility_Tests.cs
  6. 11
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/TokenProviderLifespan_Tests.cs
  7. 99
      modules/identity/test/Volo.Abp.Identity.EntityFrameworkCore.Tests/Volo/Abp/Identity/EntityFrameworkCore/CrossHostTokenProvider_Tests.cs

2
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:

2
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<IdentityUser>`; it implements `IUserTwoFactorTokenProvider<IdentityUser>` and re-implements the same protected payload, so the token format is unchanged. Code that casts a provider to `DataProtectorTokenProvider<IdentityUser>` 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<AbpDataProtectionTokenProviderOptions>`, which each provider satisfies with its own concrete options class, and `ILogger<AbpSingleActiveTokenProvider>` instead of `IOptions<DataProtectionTokenProviderOptions>` and `ILogger<DataProtectorTokenProvider<IdentityUser>>`. 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.

11
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
/// </summary>
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;

22
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<IdentityUser>, IDomainService
{
/// <summary>
/// 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.
/// </summary>
private readonly Dictionary<string, IUserTwoFactorTokenProvider<IdentityUser>> _registeredTokenProviders = new();
protected IIdentityRoleRepository RoleRepository { get; }
protected IIdentityUserRepository UserRepository { get; }
protected IOrganizationUnitRepository OrganizationUnitRepository { get; }
@ -85,6 +91,20 @@ public class IdentityUserManager : UserManager<IdentityUser>, IDomainService
CancellationTokenProvider = cancellationTokenProvider;
}
public override void RegisterTokenProvider(string providerName, IUserTwoFactorTokenProvider<IdentityUser> provider)
{
base.RegisterTokenProvider(providerName, provider);
_registeredTokenProviders[providerName] = provider;
}
/// <summary>
/// The token provider this manager uses for <paramref name="providerName"/>, or null when the key has none.
/// </summary>
public virtual IUserTwoFactorTokenProvider<IdentityUser>? FindTokenProvider(string providerName)
{
return _registeredTokenProviders.GetOrDefault(providerName);
}
public virtual async Task<IdentityResult> CreateAsync(IdentityUser user, string password, bool validatePassword)
{
var result = await UpdatePasswordHash(user, password, validatePassword);

4
modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider_Compatibility_Tests.cs → 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;
/// <see cref="DataProtectorTokenProvider{TUser}"/>, 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.
/// </summary>
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<IIdentityUserRepository>();
_userManager = GetRequiredService<IdentityUserManager>();

11
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<TOptions>(IAbpApplication application)

99
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<StockProviderGeneratorHostModule>();
using var validator = await CreateHostAsync<OptedOutGeneratorHostModule>();
try
{
var token = await WithUowAsync(generator, async sp =>
{
var userManager = sp.GetRequiredService<IdentityUserManager>();
var identityOptions = sp.GetRequiredService<IOptions<IdentityOptions>>().Value;
identityOptions.Tokens.ProviderMap[identityOptions.Tokens.PasswordResetTokenProvider].ProviderType
.ShouldBe(typeof(DataProtectorTokenProvider<IdentityUser>));
return await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId));
});
var result = await WithUowAsync(validator, async sp =>
{
var userManager = sp.GetRequiredService<IdentityUserManager>();
var identityOptions = sp.GetRequiredService<IOptions<IdentityOptions>>().Value;
identityOptions.Tokens.ProviderMap[identityOptions.Tokens.PasswordResetTokenProvider].ProviderType
.ShouldBe(typeof(DataProtectorTokenProvider<IdentityUser>));
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<SecondUserTypeHostModule>();
try
{
await WithUowAsync(secondUserTypeHost, async sp =>
{
var identityOptions = sp.GetRequiredService<IOptions<IdentityOptions>>().Value;
identityOptions.Tokens.ProviderMap[AbpPasswordResetTokenProvider.ProviderName].ProviderType
.ShouldBe(typeof(OtherUserTokenProvider));
var userManager = sp.GetRequiredService<IdentityUserManager>();
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<OtherUserTokenProvider>(AbpPasswordResetTokenProvider.ProviderName);
}
}
public class OtherUser
{
}
public class OtherUserTokenProvider : IUserTwoFactorTokenProvider<OtherUser>
{
public Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<OtherUser> manager, OtherUser user) => Task.FromResult(false);
public Task<string> GenerateAsync(string purpose, UserManager<OtherUser> manager, OtherUser user) => Task.FromResult(string.Empty);
public Task<bool> ValidateAsync(string purpose, string token, UserManager<OtherUser> 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))]

Loading…
Cancel
Save