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..c938b6db67
--- /dev/null
+++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider.cs
@@ -0,0 +1,25 @@
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Volo.Abp.Identity;
+
+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)
+ : base(dataProtectionProvider, options, logger, userRepository)
+ {
+ }
+}
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..2b758f1fc2
--- /dev/null
+++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider.cs
@@ -0,0 +1,38 @@
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Volo.Abp.Identity;
+
+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)
+ : base(dataProtectionProvider, options, logger, userRepository)
+ {
+ }
+}
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..95736bbffc
--- /dev/null
+++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider.cs
@@ -0,0 +1,25 @@
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Volo.Abp.Identity;
+
+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)
+ : base(dataProtectionProvider, options, logger, userRepository)
+ {
+ }
+}
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..baf8e4bffd
--- /dev/null
+++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider.cs
@@ -0,0 +1,73 @@
+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;
+
+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
+{
+ public const string TokenHashSuffix = "_TokenHash";
+
+ protected IIdentityUserRepository UserRepository { get; }
+
+ protected AbpSingleActiveTokenProvider(
+ IDataProtectionProvider dataProtectionProvider,
+ IOptions options,
+ ILogger> logger,
+ IIdentityUserRepository userRepository)
+ : base(dataProtectionProvider, options, logger)
+ {
+ UserRepository = userRepository;
+ }
+
+ 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);
+ var tokenHash = ComputeSha256Hash(token);
+ user.SetToken(Options.Name, purpose + TokenHashSuffix, 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);
+
+ var storedHash = user.FindToken(Options.Name, purpose + TokenHashSuffix)?.Value;
+ if (storedHash == null)
+ {
+ return false;
+ }
+
+ var inputHash = ComputeSha256Hash(token);
+ return string.Equals(storedHash, inputHash, StringComparison.Ordinal);
+ }
+
+ 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..5fa2ce89b3
--- /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 = UserManager.ResetPasswordTokenPurpose + AbpSingleActiveTokenProvider.TokenHashSuffix;
+ return manager.RemoveAuthenticationTokenAsync(user, manager.Options.Tokens.PasswordResetTokenProvider, 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 = UserManager.ConfirmEmailTokenPurpose + AbpSingleActiveTokenProvider.TokenHashSuffix;
+ return manager.RemoveAuthenticationTokenAsync(user, manager.Options.Tokens.EmailConfirmationTokenProvider, 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 = UserManager.GetChangeEmailTokenPurpose(newEmail) + AbpSingleActiveTokenProvider.TokenHashSuffix;
+ return manager.RemoveAuthenticationTokenAsync(user, manager.Options.Tokens.ChangeEmailTokenProvider, 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..f72994d582
--- /dev/null
+++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider_Tests.cs
@@ -0,0 +1,175 @@
+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 : 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 AbpChangeEmailTokenProvider_Tests()
+ {
+ UserRepository = GetRequiredService();
+ UserManager = GetRequiredService();
+ TestData = GetRequiredService();
+ UnitOfWorkManager = GetRequiredService();
+ }
+
+ [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 Generate_And_Verify_Change_Email_Token_Should_Succeed()
+ {
+ using (var uow = UnitOfWorkManager.Begin())
+ {
+ var john = await UserRepository.GetAsync(TestData.UserJohnId);
+
+ var token = await UserManager.GenerateChangeEmailTokenAsync(john, NewEmail);
+ token.ShouldNotBeNullOrEmpty();
+
+ var isValid = await UserManager.VerifyUserTokenAsync(
+ john,
+ UserManager.Options.Tokens.ChangeEmailTokenProvider,
+ UserManager.GetChangeEmailTokenPurpose(NewEmail),
+ token);
+
+ isValid.ShouldBeTrue();
+
+ await uow.CompleteAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Invalid_Token_Should_Fail_Verification()
+ {
+ using (var uow = UnitOfWorkManager.Begin())
+ {
+ var john = await UserRepository.GetAsync(TestData.UserJohnId);
+
+ await UserManager.GenerateChangeEmailTokenAsync(john, NewEmail);
+
+ var isValid = await UserManager.VerifyUserTokenAsync(
+ john,
+ UserManager.Options.Tokens.ChangeEmailTokenProvider,
+ UserManager.GetChangeEmailTokenPurpose(NewEmail),
+ "invalid-token-value");
+
+ isValid.ShouldBeFalse();
+
+ await uow.CompleteAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Second_Token_Should_Invalidate_First_Token()
+ {
+ using (var uow = UnitOfWorkManager.Begin())
+ {
+ var john = await UserRepository.GetAsync(TestData.UserJohnId);
+
+ var firstToken = await UserManager.GenerateChangeEmailTokenAsync(john, NewEmail);
+
+ john = await UserRepository.GetAsync(TestData.UserJohnId);
+ var secondToken = await UserManager.GenerateChangeEmailTokenAsync(john, NewEmail);
+
+ john = await UserRepository.GetAsync(TestData.UserJohnId);
+
+ var firstTokenValid = await UserManager.VerifyUserTokenAsync(
+ john,
+ UserManager.Options.Tokens.ChangeEmailTokenProvider,
+ UserManager.GetChangeEmailTokenPurpose(NewEmail),
+ firstToken);
+
+ var secondTokenValid = await UserManager.VerifyUserTokenAsync(
+ john,
+ UserManager.Options.Tokens.ChangeEmailTokenProvider,
+ UserManager.GetChangeEmailTokenPurpose(NewEmail),
+ secondToken);
+
+ firstTokenValid.ShouldBeFalse();
+ secondTokenValid.ShouldBeTrue();
+
+ await uow.CompleteAsync();
+ }
+ }
+
+ [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);
+ var isValid = await UserManager.VerifyUserTokenAsync(
+ john,
+ UserManager.Options.Tokens.ChangeEmailTokenProvider,
+ UserManager.GetChangeEmailTokenPurpose(NewEmail),
+ token);
+
+ isValid.ShouldBeFalse();
+
+ await uow.CompleteAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Token_Hash_Should_Persist_Across_UnitOfWork_Boundaries()
+ {
+ string token;
+
+ // UoW 1: generate the token; UpdateAsync inside GenerateAsync must persist the hash.
+ using (var uow = UnitOfWorkManager.Begin(requiresNew: true))
+ {
+ var john = await UserRepository.GetAsync(TestData.UserJohnId);
+ token = await UserManager.GenerateChangeEmailTokenAsync(john, NewEmail);
+ await uow.CompleteAsync();
+ }
+
+ // UoW 2: validate using a fresh DbContext to confirm the hash was written to the DB.
+ using (var uow = UnitOfWorkManager.Begin(requiresNew: true))
+ {
+ var john = await UserRepository.GetAsync(TestData.UserJohnId);
+ var isValid = await UserManager.VerifyUserTokenAsync(
+ john,
+ UserManager.Options.Tokens.ChangeEmailTokenProvider,
+ UserManager.GetChangeEmailTokenPurpose(NewEmail),
+ token);
+
+ isValid.ShouldBeTrue();
+ 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..b285a5e6d7
--- /dev/null
+++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider_Tests.cs
@@ -0,0 +1,178 @@
+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 : AbpIdentityAspNetCoreTestBase
+{
+ protected IIdentityUserRepository UserRepository { get; }
+ protected IdentityUserManager UserManager { get; }
+ protected IdentityTestData TestData { get; }
+ protected IUnitOfWorkManager UnitOfWorkManager { get; }
+
+ public AbpEmailConfirmationTokenProvider_Tests()
+ {
+ UserRepository = GetRequiredService();
+ UserManager = GetRequiredService();
+ TestData = GetRequiredService();
+ UnitOfWorkManager = GetRequiredService();
+ }
+
+ [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 Generate_And_Verify_Email_Confirmation_Token_Should_Succeed()
+ {
+ using (var uow = UnitOfWorkManager.Begin())
+ {
+ var john = await UserRepository.GetAsync(TestData.UserJohnId);
+
+ var token = await UserManager.GenerateEmailConfirmationTokenAsync(john);
+ token.ShouldNotBeNullOrEmpty();
+
+ var isValid = await UserManager.VerifyUserTokenAsync(
+ john,
+ UserManager.Options.Tokens.EmailConfirmationTokenProvider,
+ UserManager.ConfirmEmailTokenPurpose,
+ token);
+
+ isValid.ShouldBeTrue();
+
+ await uow.CompleteAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Invalid_Token_Should_Fail_Verification()
+ {
+ using (var uow = UnitOfWorkManager.Begin())
+ {
+ var john = await UserRepository.GetAsync(TestData.UserJohnId);
+
+ await UserManager.GenerateEmailConfirmationTokenAsync(john);
+
+ var isValid = await UserManager.VerifyUserTokenAsync(
+ john,
+ UserManager.Options.Tokens.EmailConfirmationTokenProvider,
+ UserManager.ConfirmEmailTokenPurpose,
+ "invalid-token-value");
+
+ isValid.ShouldBeFalse();
+
+ await uow.CompleteAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Second_Token_Should_Invalidate_First_Token()
+ {
+ using (var uow = UnitOfWorkManager.Begin())
+ {
+ var john = await UserRepository.GetAsync(TestData.UserJohnId);
+
+ var firstToken = await UserManager.GenerateEmailConfirmationTokenAsync(john);
+
+ john = await UserRepository.GetAsync(TestData.UserJohnId);
+ var secondToken = await UserManager.GenerateEmailConfirmationTokenAsync(john);
+
+ john = await UserRepository.GetAsync(TestData.UserJohnId);
+
+ var firstTokenValid = await UserManager.VerifyUserTokenAsync(
+ john,
+ UserManager.Options.Tokens.EmailConfirmationTokenProvider,
+ UserManager.ConfirmEmailTokenPurpose,
+ firstToken);
+
+ var secondTokenValid = await UserManager.VerifyUserTokenAsync(
+ john,
+ UserManager.Options.Tokens.EmailConfirmationTokenProvider,
+ UserManager.ConfirmEmailTokenPurpose,
+ secondToken);
+
+ firstTokenValid.ShouldBeFalse();
+ secondTokenValid.ShouldBeTrue();
+
+ await uow.CompleteAsync();
+ }
+ }
+
+ [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 that require single-use semantics must
+ // explicitly revoke the stored token hash after a successful confirmation.
+ john = await UserRepository.GetAsync(TestData.UserJohnId);
+ var removeResult = await UserManager.RemoveEmailConfirmationTokenAsync(john);
+ removeResult.Succeeded.ShouldBeTrue();
+
+ john = await UserRepository.GetAsync(TestData.UserJohnId);
+ var isValid = await UserManager.VerifyUserTokenAsync(
+ john,
+ UserManager.Options.Tokens.EmailConfirmationTokenProvider,
+ UserManager.ConfirmEmailTokenPurpose,
+ token);
+
+ isValid.ShouldBeFalse();
+
+ await uow.CompleteAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Token_Hash_Should_Persist_Across_UnitOfWork_Boundaries()
+ {
+ string token;
+
+ // UoW 1: generate the token; UpdateAsync inside GenerateAsync must persist the hash.
+ using (var uow = UnitOfWorkManager.Begin(requiresNew: true))
+ {
+ var john = await UserRepository.GetAsync(TestData.UserJohnId);
+ token = await UserManager.GenerateEmailConfirmationTokenAsync(john);
+ await uow.CompleteAsync();
+ }
+
+ // UoW 2: validate using a fresh DbContext to confirm the hash was written to the DB.
+ using (var uow = UnitOfWorkManager.Begin(requiresNew: true))
+ {
+ var john = await UserRepository.GetAsync(TestData.UserJohnId);
+ var isValid = await UserManager.VerifyUserTokenAsync(
+ john,
+ UserManager.Options.Tokens.EmailConfirmationTokenProvider,
+ UserManager.ConfirmEmailTokenPurpose,
+ token);
+
+ isValid.ShouldBeTrue();
+ 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..d0eec0ca94
--- /dev/null
+++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider_Tests.cs
@@ -0,0 +1,173 @@
+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 : AbpIdentityAspNetCoreTestBase
+{
+ protected IIdentityUserRepository UserRepository { get; }
+ protected IdentityUserManager UserManager { get; }
+ protected IdentityTestData TestData { get; }
+ protected IUnitOfWorkManager UnitOfWorkManager { get; }
+
+ public AbpPasswordResetTokenProvider_Tests()
+ {
+ UserRepository = GetRequiredService();
+ UserManager = GetRequiredService();
+ TestData = GetRequiredService();
+ UnitOfWorkManager = GetRequiredService();
+ }
+
+ [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 Generate_And_Verify_Password_Reset_Token_Should_Succeed()
+ {
+ using (var uow = UnitOfWorkManager.Begin())
+ {
+ var john = await UserRepository.GetAsync(TestData.UserJohnId);
+
+ var token = await UserManager.GeneratePasswordResetTokenAsync(john);
+ token.ShouldNotBeNullOrEmpty();
+
+ var isValid = await UserManager.VerifyUserTokenAsync(
+ john,
+ UserManager.Options.Tokens.PasswordResetTokenProvider,
+ UserManager.ResetPasswordTokenPurpose,
+ token);
+
+ isValid.ShouldBeTrue();
+
+ await uow.CompleteAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Invalid_Token_Should_Fail_Verification()
+ {
+ using (var uow = UnitOfWorkManager.Begin())
+ {
+ var john = await UserRepository.GetAsync(TestData.UserJohnId);
+
+ await UserManager.GeneratePasswordResetTokenAsync(john);
+
+ var isValid = await UserManager.VerifyUserTokenAsync(
+ john,
+ UserManager.Options.Tokens.PasswordResetTokenProvider,
+ UserManager.ResetPasswordTokenPurpose,
+ "invalid-token-value");
+
+ isValid.ShouldBeFalse();
+
+ await uow.CompleteAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Second_Token_Should_Invalidate_First_Token()
+ {
+ using (var uow = UnitOfWorkManager.Begin())
+ {
+ var john = await UserRepository.GetAsync(TestData.UserJohnId);
+
+ var firstToken = await UserManager.GeneratePasswordResetTokenAsync(john);
+
+ john = await UserRepository.GetAsync(TestData.UserJohnId);
+ var secondToken = await UserManager.GeneratePasswordResetTokenAsync(john);
+
+ john = await UserRepository.GetAsync(TestData.UserJohnId);
+
+ var firstTokenValid = await UserManager.VerifyUserTokenAsync(
+ john,
+ UserManager.Options.Tokens.PasswordResetTokenProvider,
+ UserManager.ResetPasswordTokenPurpose,
+ firstToken);
+
+ var secondTokenValid = await UserManager.VerifyUserTokenAsync(
+ john,
+ UserManager.Options.Tokens.PasswordResetTokenProvider,
+ UserManager.ResetPasswordTokenPurpose,
+ secondToken);
+
+ firstTokenValid.ShouldBeFalse();
+ secondTokenValid.ShouldBeTrue();
+
+ await uow.CompleteAsync();
+ }
+ }
+
+ [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);
+ var isValid = await UserManager.VerifyUserTokenAsync(
+ john,
+ UserManager.Options.Tokens.PasswordResetTokenProvider,
+ UserManager.ResetPasswordTokenPurpose,
+ token);
+
+ isValid.ShouldBeFalse();
+
+ await uow.CompleteAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Token_Hash_Should_Persist_Across_UnitOfWork_Boundaries()
+ {
+ string token;
+
+ // UoW 1: generate the token; UpdateAsync inside GenerateAsync must persist the hash.
+ using (var uow = UnitOfWorkManager.Begin(requiresNew: true))
+ {
+ var john = await UserRepository.GetAsync(TestData.UserJohnId);
+ token = await UserManager.GeneratePasswordResetTokenAsync(john);
+ await uow.CompleteAsync();
+ }
+
+ // UoW 2: validate using a fresh DbContext to confirm the hash was written to the DB.
+ using (var uow = UnitOfWorkManager.Begin(requiresNew: true))
+ {
+ var john = await UserRepository.GetAsync(TestData.UserJohnId);
+ var isValid = await UserManager.VerifyUserTokenAsync(
+ john,
+ UserManager.Options.Tokens.PasswordResetTokenProvider,
+ UserManager.ResetPasswordTokenPurpose,
+ token);
+
+ isValid.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..93b06d0019
--- /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 = UserManager.Options.Tokens.PasswordResetTokenProvider;
+ var tokenKey = UserManager.ResetPasswordTokenPurpose + AbpSingleActiveTokenProvider.TokenHashSuffix;
+
+ 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 = UserManager.Options.Tokens.EmailConfirmationTokenProvider;
+ var tokenKey = UserManager.ConfirmEmailTokenPurpose + AbpSingleActiveTokenProvider.TokenHashSuffix;
+
+ 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 = UserManager.Options.Tokens.ChangeEmailTokenProvider;
+ var tokenKey = UserManager.GetChangeEmailTokenPurpose(NewEmail) + AbpSingleActiveTokenProvider.TokenHashSuffix;
+
+ 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();
+ }
+ }
+}