diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider.cs
new file mode 100644
index 0000000000..344c188264
--- /dev/null
+++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider.cs
@@ -0,0 +1,27 @@
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Volo.Abp.Identity;
+using Volo.Abp.Threading;
+
+namespace Volo.Abp.Identity.AspNetCore;
+
+///
+/// Change email token provider that enforces only the most recently issued
+/// token to be valid, with a configurable expiration period.
+///
+public class AbpChangeEmailTokenProvider : AbpSingleActiveTokenProvider
+{
+ public const string ProviderName = "AbpChangeEmail";
+
+ public AbpChangeEmailTokenProvider(
+ IDataProtectionProvider dataProtectionProvider,
+ IOptions options,
+ ILogger> logger,
+ IIdentityUserRepository userRepository,
+ ICancellationTokenProvider cancellationTokenProvider)
+ : base(dataProtectionProvider, options, logger, userRepository, cancellationTokenProvider)
+ {
+ }
+}
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProviderOptions.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProviderOptions.cs
new file mode 100644
index 0000000000..f1e201d240
--- /dev/null
+++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProviderOptions.cs
@@ -0,0 +1,13 @@
+using System;
+using Microsoft.AspNetCore.Identity;
+
+namespace Volo.Abp.Identity.AspNetCore;
+
+public class AbpChangeEmailTokenProviderOptions : DataProtectionTokenProviderOptions
+{
+ public AbpChangeEmailTokenProviderOptions()
+ {
+ Name = AbpChangeEmailTokenProvider.ProviderName;
+ TokenLifespan = TimeSpan.FromHours(2);
+ }
+}
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider.cs
new file mode 100644
index 0000000000..1f99543f8f
--- /dev/null
+++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider.cs
@@ -0,0 +1,40 @@
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Volo.Abp.Identity;
+using Volo.Abp.Threading;
+
+namespace Volo.Abp.Identity.AspNetCore;
+
+///
+/// Email confirmation token provider that enforces only the most recently issued
+/// token to be valid, with a configurable expiration period.
+/// Token reuse is bounded by the token expiry and the single-active policy:
+/// generating a new token overwrites the stored hash, invalidating all previous tokens.
+///
+/// Unlike password-reset and change-email flows,
+/// does not update the security stamp, so the token hash is NOT automatically
+/// invalidated after a successful confirmation. Callers that require single-use semantics
+/// must explicitly revoke the hash after confirmation:
+///
+/// var result = await userManager.ConfirmEmailAsync(user, token);
+/// if (result.Succeeded)
+/// await userManager.RemoveEmailConfirmationTokenAsync(user);
+///
+///
+///
+public class AbpEmailConfirmationTokenProvider : AbpSingleActiveTokenProvider
+{
+ public const string ProviderName = "AbpEmailConfirmation";
+
+ public AbpEmailConfirmationTokenProvider(
+ IDataProtectionProvider dataProtectionProvider,
+ IOptions options,
+ ILogger> logger,
+ IIdentityUserRepository userRepository,
+ ICancellationTokenProvider cancellationTokenProvider)
+ : base(dataProtectionProvider, options, logger, userRepository, cancellationTokenProvider)
+ {
+ }
+}
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProviderOptions.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProviderOptions.cs
new file mode 100644
index 0000000000..34909360f2
--- /dev/null
+++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProviderOptions.cs
@@ -0,0 +1,13 @@
+using System;
+using Microsoft.AspNetCore.Identity;
+
+namespace Volo.Abp.Identity.AspNetCore;
+
+public class AbpEmailConfirmationTokenProviderOptions : DataProtectionTokenProviderOptions
+{
+ public AbpEmailConfirmationTokenProviderOptions()
+ {
+ Name = AbpEmailConfirmationTokenProvider.ProviderName;
+ TokenLifespan = TimeSpan.FromHours(2);
+ }
+}
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpIdentityAspNetCoreModule.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpIdentityAspNetCoreModule.cs
index e06797ae54..3284a31601 100644
--- a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpIdentityAspNetCoreModule.cs
+++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpIdentityAspNetCoreModule.cs
@@ -20,6 +20,9 @@ public class AbpIdentityAspNetCoreModule : AbpModule
builder
.AddDefaultTokenProviders()
.AddTokenProvider(LinkUserTokenProviderConsts.LinkUserTokenProviderName)
+ .AddTokenProvider(AbpPasswordResetTokenProvider.ProviderName)
+ .AddTokenProvider(AbpEmailConfirmationTokenProvider.ProviderName)
+ .AddTokenProvider(AbpChangeEmailTokenProvider.ProviderName)
.AddSignInManager()
.AddUserValidator();
});
@@ -27,6 +30,13 @@ public class AbpIdentityAspNetCoreModule : AbpModule
public override void ConfigureServices(ServiceConfigurationContext context)
{
+ Configure(options =>
+ {
+ options.Tokens.PasswordResetTokenProvider = AbpPasswordResetTokenProvider.ProviderName;
+ options.Tokens.EmailConfirmationTokenProvider = AbpEmailConfirmationTokenProvider.ProviderName;
+ options.Tokens.ChangeEmailTokenProvider = AbpChangeEmailTokenProvider.ProviderName;
+ });
+
//(TODO: Extract an extension method like IdentityBuilder.AddAbpSecurityStampValidator())
context.Services.AddScoped();
context.Services.AddScoped(typeof(SecurityStampValidator), provider => provider.GetService(typeof(AbpSecurityStampValidator)));
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider.cs
new file mode 100644
index 0000000000..cc3c960804
--- /dev/null
+++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider.cs
@@ -0,0 +1,27 @@
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Volo.Abp.Identity;
+using Volo.Abp.Threading;
+
+namespace Volo.Abp.Identity.AspNetCore;
+
+///
+/// Password reset token provider that enforces only the most recently issued
+/// token to be valid, with a configurable expiration period.
+///
+public class AbpPasswordResetTokenProvider : AbpSingleActiveTokenProvider
+{
+ public const string ProviderName = "AbpPasswordReset";
+
+ public AbpPasswordResetTokenProvider(
+ IDataProtectionProvider dataProtectionProvider,
+ IOptions options,
+ ILogger> logger,
+ IIdentityUserRepository userRepository,
+ ICancellationTokenProvider cancellationTokenProvider)
+ : base(dataProtectionProvider, options, logger, userRepository, cancellationTokenProvider)
+ {
+ }
+}
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProviderOptions.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProviderOptions.cs
new file mode 100644
index 0000000000..15b42acad5
--- /dev/null
+++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProviderOptions.cs
@@ -0,0 +1,13 @@
+using System;
+using Microsoft.AspNetCore.Identity;
+
+namespace Volo.Abp.Identity.AspNetCore;
+
+public class AbpPasswordResetTokenProviderOptions : DataProtectionTokenProviderOptions
+{
+ public AbpPasswordResetTokenProviderOptions()
+ {
+ Name = AbpPasswordResetTokenProvider.ProviderName;
+ TokenLifespan = TimeSpan.FromHours(2);
+ }
+}
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider.cs
new file mode 100644
index 0000000000..aa6454085c
--- /dev/null
+++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Volo.Abp.Domain.Repositories;
+using Volo.Abp.Identity;
+using Volo.Abp.Threading;
+
+namespace Volo.Abp.Identity.AspNetCore;
+
+///
+/// Base class for ABP token providers that enforce a "single active token" policy:
+/// generating a new token automatically invalidates all previously issued tokens.
+/// Token validity is enforced by SecurityStamp verification (via the base class) and
+/// by the stored hash, which is overwritten each time a new token is generated.
+///
+public abstract class AbpSingleActiveTokenProvider : DataProtectorTokenProvider
+{
+ ///
+ /// The internal login provider name used to store token hashes in the user token table.
+ /// Using a bracketed name clearly distinguishes these internal entries from real external
+ /// login providers (e.g. Google, GitHub) stored in the same table.
+ ///
+ public const string InternalLoginProvider = "[AbpSingleActiveToken]";
+
+ protected IIdentityUserRepository UserRepository { get; }
+
+ protected ICancellationTokenProvider CancellationTokenProvider { get; }
+
+ protected AbpSingleActiveTokenProvider(
+ IDataProtectionProvider dataProtectionProvider,
+ IOptions options,
+ ILogger> logger,
+ IIdentityUserRepository userRepository,
+ ICancellationTokenProvider cancellationTokenProvider)
+ : base(dataProtectionProvider, options, logger)
+ {
+ UserRepository = userRepository;
+ CancellationTokenProvider = cancellationTokenProvider;
+ }
+
+ public override async Task GenerateAsync(string purpose, UserManager manager, IdentityUser user)
+ {
+ var token = await base.GenerateAsync(purpose, manager, user);
+
+ await UserRepository.EnsureCollectionLoadedAsync(user, u => u.Tokens, CancellationTokenProvider.Token);
+ var tokenHash = ComputeSha256Hash(token);
+ user.SetToken(InternalLoginProvider, Options.Name + ":" + purpose, tokenHash);
+
+ await manager.UpdateAsync(user);
+
+ return token;
+ }
+
+ public override async Task ValidateAsync(string purpose, string token, UserManager manager, IdentityUser user)
+ {
+ if (!await base.ValidateAsync(purpose, token, manager, user))
+ {
+ return false;
+ }
+
+ await UserRepository.EnsureCollectionLoadedAsync(user, u => u.Tokens, CancellationTokenProvider.Token);
+
+ var storedHash = user.FindToken(InternalLoginProvider, Options.Name + ":" + purpose)?.Value;
+ if (storedHash == null)
+ {
+ return false;
+ }
+
+ var inputHash = ComputeSha256Hash(token);
+ try
+ {
+ var storedHashBytes = Convert.FromHexString(storedHash);
+ var inputHashBytes = Convert.FromHexString(inputHash);
+ return CryptographicOperations.FixedTimeEquals(storedHashBytes, inputHashBytes);
+ }
+ catch (FormatException)
+ {
+ // In case the stored hash is corrupted or not a valid hex string,
+ // treat the token as invalid rather than throwing.
+ return false;
+ }
+ }
+
+ protected virtual string ComputeSha256Hash(string input)
+ {
+ var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
+ return Convert.ToHexString(bytes);
+ }
+}
diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions.cs
new file mode 100644
index 0000000000..5b721241fd
--- /dev/null
+++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions.cs
@@ -0,0 +1,43 @@
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Identity;
+
+namespace Volo.Abp.Identity.AspNetCore;
+
+///
+/// Provides extension methods on for invalidating
+/// single-active tokens managed by .
+/// These helpers live in the AspNetCore layer because they depend on
+/// .
+///
+public static class IdentityUserManagerSingleActiveTokenExtensions
+{
+ ///
+ /// Removes the stored password-reset token hash for ,
+ /// immediately invalidating any previously issued password-reset token.
+ ///
+ public static Task RemovePasswordResetTokenAsync(this IdentityUserManager manager, IdentityUser user)
+ {
+ var name = manager.Options.Tokens.PasswordResetTokenProvider + ":" + UserManager.ResetPasswordTokenPurpose;
+ return manager.RemoveAuthenticationTokenAsync(user, AbpSingleActiveTokenProvider.InternalLoginProvider, name);
+ }
+
+ ///
+ /// Removes the stored email-confirmation token hash for ,
+ /// immediately invalidating any previously issued email-confirmation token.
+ ///
+ public static Task RemoveEmailConfirmationTokenAsync(this IdentityUserManager manager, IdentityUser user)
+ {
+ var name = manager.Options.Tokens.EmailConfirmationTokenProvider + ":" + UserManager.ConfirmEmailTokenPurpose;
+ return manager.RemoveAuthenticationTokenAsync(user, AbpSingleActiveTokenProvider.InternalLoginProvider, name);
+ }
+
+ ///
+ /// Removes the stored change-email token hash for ,
+ /// immediately invalidating any previously issued change-email token for .
+ ///
+ public static Task RemoveChangeEmailTokenAsync(this IdentityUserManager manager, IdentityUser user, string newEmail)
+ {
+ var name = manager.Options.Tokens.ChangeEmailTokenProvider + ":" + UserManager.GetChangeEmailTokenPurpose(newEmail);
+ return manager.RemoveAuthenticationTokenAsync(user, AbpSingleActiveTokenProvider.InternalLoginProvider, name);
+ }
+}
diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider_Tests.cs
new file mode 100644
index 0000000000..dee4ebe068
--- /dev/null
+++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider_Tests.cs
@@ -0,0 +1,68 @@
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Options;
+using Shouldly;
+using Volo.Abp.Uow;
+using Xunit;
+
+namespace Volo.Abp.Identity.AspNetCore;
+
+public class AbpChangeEmailTokenProvider_Tests : AbpSingleActiveTokenProviderTestBase
+{
+ private const string NewEmail = "newemail@example.com";
+
+ protected override Task GenerateTokenAsync(IdentityUser user)
+ => UserManager.GenerateChangeEmailTokenAsync(user, NewEmail);
+
+ protected override Task VerifyTokenAsync(IdentityUser user, string token)
+ => UserManager.VerifyUserTokenAsync(
+ user,
+ UserManager.Options.Tokens.ChangeEmailTokenProvider,
+ UserManager.GetChangeEmailTokenPurpose(NewEmail),
+ token);
+
+ protected override string GetProviderName()
+ => UserManager.Options.Tokens.ChangeEmailTokenProvider;
+
+ protected override string GetPurpose()
+ => UserManager.GetChangeEmailTokenPurpose(NewEmail);
+
+ [Fact]
+ public void AbpChangeEmailTokenProvider_Should_Be_Registered()
+ {
+ var identityOptions = GetRequiredService>().Value;
+
+ identityOptions.Tokens.ProviderMap.ShouldContainKey(AbpChangeEmailTokenProvider.ProviderName);
+ identityOptions.Tokens.ProviderMap[AbpChangeEmailTokenProvider.ProviderName].ProviderType
+ .ShouldBe(typeof(AbpChangeEmailTokenProvider));
+ }
+
+ [Fact]
+ public void ChangeEmailTokenProvider_Should_Be_Configured_As_Abp()
+ {
+ var identityOptions = GetRequiredService>().Value;
+
+ identityOptions.Tokens.ChangeEmailTokenProvider.ShouldBe(AbpChangeEmailTokenProvider.ProviderName);
+ }
+
+ [Fact]
+ public async Task Token_Should_Become_Invalid_After_Email_Change()
+ {
+ using (var uow = UnitOfWorkManager.Begin())
+ {
+ var john = await UserRepository.GetAsync(TestData.UserJohnId);
+
+ var token = await UserManager.GenerateChangeEmailTokenAsync(john, NewEmail);
+
+ john = await UserRepository.GetAsync(TestData.UserJohnId);
+ var result = await UserManager.ChangeEmailAsync(john, NewEmail, token);
+ result.Succeeded.ShouldBeTrue();
+
+ // SecurityStamp has changed after the email change, so the old token must be invalid.
+ john = await UserRepository.GetAsync(TestData.UserJohnId);
+ (await VerifyTokenAsync(john, token)).ShouldBeFalse();
+
+ await uow.CompleteAsync();
+ }
+ }
+}
diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider_Tests.cs
new file mode 100644
index 0000000000..fe8d5c7de8
--- /dev/null
+++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider_Tests.cs
@@ -0,0 +1,69 @@
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Options;
+using Shouldly;
+using Volo.Abp.Uow;
+using Xunit;
+
+namespace Volo.Abp.Identity.AspNetCore;
+
+public class AbpEmailConfirmationTokenProvider_Tests : AbpSingleActiveTokenProviderTestBase
+{
+ protected override Task GenerateTokenAsync(IdentityUser user)
+ => UserManager.GenerateEmailConfirmationTokenAsync(user);
+
+ protected override Task VerifyTokenAsync(IdentityUser user, string token)
+ => UserManager.VerifyUserTokenAsync(
+ user,
+ UserManager.Options.Tokens.EmailConfirmationTokenProvider,
+ UserManager.ConfirmEmailTokenPurpose,
+ token);
+
+ protected override string GetProviderName()
+ => UserManager.Options.Tokens.EmailConfirmationTokenProvider;
+
+ protected override string GetPurpose()
+ => UserManager.ConfirmEmailTokenPurpose;
+
+ [Fact]
+ public void AbpEmailConfirmationTokenProvider_Should_Be_Registered()
+ {
+ var identityOptions = GetRequiredService>().Value;
+
+ identityOptions.Tokens.ProviderMap.ShouldContainKey(AbpEmailConfirmationTokenProvider.ProviderName);
+ identityOptions.Tokens.ProviderMap[AbpEmailConfirmationTokenProvider.ProviderName].ProviderType
+ .ShouldBe(typeof(AbpEmailConfirmationTokenProvider));
+ }
+
+ [Fact]
+ public void EmailConfirmationTokenProvider_Should_Be_Configured_As_Abp()
+ {
+ var identityOptions = GetRequiredService>().Value;
+
+ identityOptions.Tokens.EmailConfirmationTokenProvider.ShouldBe(AbpEmailConfirmationTokenProvider.ProviderName);
+ }
+
+ [Fact]
+ public async Task Token_Should_Become_Invalid_After_Email_Confirmation_With_Explicit_Revocation()
+ {
+ using (var uow = UnitOfWorkManager.Begin())
+ {
+ var john = await UserRepository.GetAsync(TestData.UserJohnId);
+
+ var token = await UserManager.GenerateEmailConfirmationTokenAsync(john);
+
+ var result = await UserManager.ConfirmEmailAsync(john, token);
+ result.Succeeded.ShouldBeTrue();
+
+ // ConfirmEmailAsync does NOT update SecurityStamp, so the hash is not
+ // automatically invalidated. Callers must explicitly revoke the hash.
+ john = await UserRepository.GetAsync(TestData.UserJohnId);
+ (await UserManager.RemoveEmailConfirmationTokenAsync(john)).Succeeded.ShouldBeTrue();
+
+ john = await UserRepository.GetAsync(TestData.UserJohnId);
+ (await VerifyTokenAsync(john, token)).ShouldBeFalse();
+
+ await uow.CompleteAsync();
+ }
+ }
+}
diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider_Tests.cs
new file mode 100644
index 0000000000..870829a975
--- /dev/null
+++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider_Tests.cs
@@ -0,0 +1,66 @@
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Options;
+using Shouldly;
+using Volo.Abp.Uow;
+using Xunit;
+
+namespace Volo.Abp.Identity.AspNetCore;
+
+public class AbpPasswordResetTokenProvider_Tests : AbpSingleActiveTokenProviderTestBase
+{
+ protected override Task GenerateTokenAsync(IdentityUser user)
+ => UserManager.GeneratePasswordResetTokenAsync(user);
+
+ protected override Task VerifyTokenAsync(IdentityUser user, string token)
+ => UserManager.VerifyUserTokenAsync(
+ user,
+ UserManager.Options.Tokens.PasswordResetTokenProvider,
+ UserManager.ResetPasswordTokenPurpose,
+ token);
+
+ protected override string GetProviderName()
+ => UserManager.Options.Tokens.PasswordResetTokenProvider;
+
+ protected override string GetPurpose()
+ => UserManager.ResetPasswordTokenPurpose;
+
+ [Fact]
+ public void AbpPasswordResetTokenProvider_Should_Be_Registered()
+ {
+ var identityOptions = GetRequiredService>().Value;
+
+ identityOptions.Tokens.ProviderMap.ShouldContainKey(AbpPasswordResetTokenProvider.ProviderName);
+ identityOptions.Tokens.ProviderMap[AbpPasswordResetTokenProvider.ProviderName].ProviderType
+ .ShouldBe(typeof(AbpPasswordResetTokenProvider));
+ }
+
+ [Fact]
+ public void PasswordResetTokenProvider_Should_Be_Configured_As_Abp()
+ {
+ var identityOptions = GetRequiredService>().Value;
+
+ identityOptions.Tokens.PasswordResetTokenProvider.ShouldBe(AbpPasswordResetTokenProvider.ProviderName);
+ }
+
+ [Fact]
+ public async Task Token_Should_Become_Invalid_After_Password_Reset()
+ {
+ using (var uow = UnitOfWorkManager.Begin())
+ {
+ var john = await UserRepository.GetAsync(TestData.UserJohnId);
+
+ var token = await UserManager.GeneratePasswordResetTokenAsync(john);
+
+ john = await UserRepository.GetAsync(TestData.UserJohnId);
+ var result = await UserManager.ResetPasswordAsync(john, token, "1q2w3E*NewP@ss!");
+ result.Succeeded.ShouldBeTrue();
+
+ // SecurityStamp has changed after reset, so the old token must be invalid.
+ john = await UserRepository.GetAsync(TestData.UserJohnId);
+ (await VerifyTokenAsync(john, token)).ShouldBeFalse();
+
+ await uow.CompleteAsync();
+ }
+ }
+}
diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProviderTestBase.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProviderTestBase.cs
new file mode 100644
index 0000000000..4f42391a05
--- /dev/null
+++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProviderTestBase.cs
@@ -0,0 +1,138 @@
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Identity;
+using Shouldly;
+using Volo.Abp.Uow;
+using Xunit;
+
+namespace Volo.Abp.Identity.AspNetCore;
+
+///
+/// Abstract base class that exercises the common behaviour of every
+/// subclass.
+/// Concrete subclasses inject their provider-specific generate/verify helpers
+/// so the same test suite runs against each provider.
+///
+public abstract class AbpSingleActiveTokenProviderTestBase : AbpIdentityAspNetCoreTestBase
+{
+ protected IIdentityUserRepository UserRepository { get; }
+ protected IdentityUserManager UserManager { get; }
+ protected IdentityTestData TestData { get; }
+ protected IUnitOfWorkManager UnitOfWorkManager { get; }
+
+ protected AbpSingleActiveTokenProviderTestBase()
+ {
+ UserRepository = GetRequiredService();
+ UserManager = GetRequiredService();
+ TestData = GetRequiredService();
+ UnitOfWorkManager = GetRequiredService();
+ }
+
+ /// Generates a token for via the provider under test.
+ protected abstract Task GenerateTokenAsync(IdentityUser user);
+
+ /// Verifies for via the provider under test.
+ protected abstract Task VerifyTokenAsync(IdentityUser user, string token);
+
+ /// Returns the provider name used to look up the stored hash.
+ protected abstract string GetProviderName();
+
+ /// Returns the token purpose used as the hash key prefix.
+ protected abstract string GetPurpose();
+
+ private string GetTokenHashName() => GetProviderName() + ":" + GetPurpose();
+
+ [Fact]
+ public async Task Generate_And_Verify_Token_Should_Succeed()
+ {
+ using (var uow = UnitOfWorkManager.Begin())
+ {
+ var user = await UserRepository.GetAsync(TestData.UserJohnId);
+
+ var token = await GenerateTokenAsync(user);
+ token.ShouldNotBeNullOrEmpty();
+
+ user = await UserRepository.GetAsync(TestData.UserJohnId);
+ (await VerifyTokenAsync(user, token)).ShouldBeTrue();
+
+ await uow.CompleteAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Invalid_Token_Should_Fail_Verification()
+ {
+ using (var uow = UnitOfWorkManager.Begin())
+ {
+ var user = await UserRepository.GetAsync(TestData.UserJohnId);
+ await GenerateTokenAsync(user);
+
+ user = await UserRepository.GetAsync(TestData.UserJohnId);
+ (await VerifyTokenAsync(user, "invalid-token-value")).ShouldBeFalse();
+
+ await uow.CompleteAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Second_Token_Should_Invalidate_First_Token()
+ {
+ using (var uow = UnitOfWorkManager.Begin())
+ {
+ var user = await UserRepository.GetAsync(TestData.UserJohnId);
+ var firstToken = await GenerateTokenAsync(user);
+
+ user = await UserRepository.GetAsync(TestData.UserJohnId);
+ var secondToken = await GenerateTokenAsync(user);
+
+ user = await UserRepository.GetAsync(TestData.UserJohnId);
+
+ (await VerifyTokenAsync(user, firstToken)).ShouldBeFalse();
+ (await VerifyTokenAsync(user, secondToken)).ShouldBeTrue();
+
+ await uow.CompleteAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Corrupted_Hash_Should_Return_False_Instead_Of_Throwing()
+ {
+ using (var uow = UnitOfWorkManager.Begin())
+ {
+ var user = await UserRepository.GetAsync(TestData.UserJohnId);
+ var token = await GenerateTokenAsync(user);
+
+ // Overwrite with a non-hex string to simulate data corruption.
+ user = await UserRepository.GetAsync(TestData.UserJohnId);
+ await UserManager.SetAuthenticationTokenAsync(user, AbpSingleActiveTokenProvider.InternalLoginProvider, GetTokenHashName(), "not-valid-hex!!!");
+
+ user = await UserRepository.GetAsync(TestData.UserJohnId);
+
+ // ValidateAsync must catch FormatException internally and return false.
+ (await VerifyTokenAsync(user, token)).ShouldBeFalse();
+
+ await uow.CompleteAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Token_Hash_Should_Persist_Across_UnitOfWork_Boundaries()
+ {
+ string token;
+
+ // UoW 1: generate; UpdateAsync inside GenerateAsync must write the hash to the DB.
+ using (var uow = UnitOfWorkManager.Begin(requiresNew: true))
+ {
+ var user = await UserRepository.GetAsync(TestData.UserJohnId);
+ token = await GenerateTokenAsync(user);
+ await uow.CompleteAsync();
+ }
+
+ // UoW 2: validate with a fresh DbContext to confirm the hash was persisted.
+ using (var uow = UnitOfWorkManager.Begin(requiresNew: true))
+ {
+ var user = await UserRepository.GetAsync(TestData.UserJohnId);
+ (await VerifyTokenAsync(user, token)).ShouldBeTrue();
+ await uow.CompleteAsync();
+ }
+ }
+}
diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions_Tests.cs
new file mode 100644
index 0000000000..10beee48dd
--- /dev/null
+++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions_Tests.cs
@@ -0,0 +1,89 @@
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Identity;
+using Shouldly;
+using Volo.Abp.Identity;
+using Volo.Abp.Uow;
+using Xunit;
+
+namespace Volo.Abp.Identity.AspNetCore;
+
+public class IdentityUserManagerSingleActiveTokenExtensions_Tests : AbpIdentityAspNetCoreTestBase
+{
+ private const string NewEmail = "newemail@example.com";
+
+ protected IIdentityUserRepository UserRepository { get; }
+ protected IdentityUserManager UserManager { get; }
+ protected IdentityTestData TestData { get; }
+ protected IUnitOfWorkManager UnitOfWorkManager { get; }
+
+ public IdentityUserManagerSingleActiveTokenExtensions_Tests()
+ {
+ UserRepository = GetRequiredService();
+ UserManager = GetRequiredService();
+ TestData = GetRequiredService();
+ UnitOfWorkManager = GetRequiredService();
+ }
+
+ [Fact]
+ public async Task Should_Remove_PasswordReset_TokenHash()
+ {
+ using (var uow = UnitOfWorkManager.Begin())
+ {
+ var user = await UserRepository.GetAsync(TestData.UserJohnId);
+ var providerName = AbpSingleActiveTokenProvider.InternalLoginProvider;
+ var tokenKey = UserManager.Options.Tokens.PasswordResetTokenProvider + ":" + UserManager.ResetPasswordTokenPurpose;
+
+ await UserManager.SetAuthenticationTokenAsync(user, providerName, tokenKey, "hash-value");
+ (await UserManager.GetAuthenticationTokenAsync(user, providerName, tokenKey)).ShouldNotBeNull();
+
+ var result = await UserManager.RemovePasswordResetTokenAsync(user);
+ result.Succeeded.ShouldBeTrue();
+
+ (await UserManager.GetAuthenticationTokenAsync(user, providerName, tokenKey)).ShouldBeNull();
+
+ await uow.CompleteAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Should_Remove_EmailConfirmation_TokenHash()
+ {
+ using (var uow = UnitOfWorkManager.Begin())
+ {
+ var user = await UserRepository.GetAsync(TestData.UserJohnId);
+ var providerName = AbpSingleActiveTokenProvider.InternalLoginProvider;
+ var tokenKey = UserManager.Options.Tokens.EmailConfirmationTokenProvider + ":" + UserManager.ConfirmEmailTokenPurpose;
+
+ await UserManager.SetAuthenticationTokenAsync(user, providerName, tokenKey, "hash-value");
+ (await UserManager.GetAuthenticationTokenAsync(user, providerName, tokenKey)).ShouldNotBeNull();
+
+ var result = await UserManager.RemoveEmailConfirmationTokenAsync(user);
+ result.Succeeded.ShouldBeTrue();
+
+ (await UserManager.GetAuthenticationTokenAsync(user, providerName, tokenKey)).ShouldBeNull();
+
+ await uow.CompleteAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Should_Remove_ChangeEmail_TokenHash()
+ {
+ using (var uow = UnitOfWorkManager.Begin())
+ {
+ var user = await UserRepository.GetAsync(TestData.UserJohnId);
+ var providerName = AbpSingleActiveTokenProvider.InternalLoginProvider;
+ var tokenKey = UserManager.Options.Tokens.ChangeEmailTokenProvider + ":" + UserManager.GetChangeEmailTokenPurpose(NewEmail);
+
+ await UserManager.SetAuthenticationTokenAsync(user, providerName, tokenKey, "hash-value");
+ (await UserManager.GetAuthenticationTokenAsync(user, providerName, tokenKey)).ShouldNotBeNull();
+
+ var result = await UserManager.RemoveChangeEmailTokenAsync(user, NewEmail);
+ result.Succeeded.ShouldBeTrue();
+
+ (await UserManager.GetAuthenticationTokenAsync(user, providerName, tokenKey)).ShouldBeNull();
+
+ await uow.CompleteAsync();
+ }
+ }
+}