From 71b52a8ee2917d2d7238f687cd01fd5039c8731e Mon Sep 17 00:00:00 2001 From: maliming Date: Mon, 23 Feb 2026 19:03:32 +0800 Subject: [PATCH 1/3] Implement single active token providers for email change, email confirmation, and password reset --- .../AspNetCore/AbpChangeEmailTokenProvider.cs | 25 +++ .../AbpChangeEmailTokenProviderOptions.cs | 13 ++ .../AbpEmailConfirmationTokenProvider.cs | 38 ++++ ...bpEmailConfirmationTokenProviderOptions.cs | 13 ++ .../AspNetCore/AbpIdentityAspNetCoreModule.cs | 10 + .../AbpPasswordResetTokenProvider.cs | 25 +++ .../AbpPasswordResetTokenProviderOptions.cs | 13 ++ .../AbpSingleActiveTokenProvider.cs | 73 +++++++ ...yUserManagerSingleActiveTokenExtensions.cs | 43 +++++ .../AbpChangeEmailTokenProvider_Tests.cs | 175 +++++++++++++++++ ...AbpEmailConfirmationTokenProvider_Tests.cs | 178 ++++++++++++++++++ .../AbpPasswordResetTokenProvider_Tests.cs | 173 +++++++++++++++++ ...anagerSingleActiveTokenExtensions_Tests.cs | 89 +++++++++ 13 files changed, 868 insertions(+) create mode 100644 modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider.cs create mode 100644 modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProviderOptions.cs create mode 100644 modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider.cs create mode 100644 modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProviderOptions.cs create mode 100644 modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider.cs create mode 100644 modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProviderOptions.cs create mode 100644 modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider.cs create mode 100644 modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions.cs create mode 100644 modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider_Tests.cs create mode 100644 modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider_Tests.cs create mode 100644 modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider_Tests.cs create mode 100644 modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions_Tests.cs 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(); + } + } +} From 6e97ceb0df027dbce34074208df33f196f376ef1 Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 24 Feb 2026 08:49:39 +0800 Subject: [PATCH 2/3] Refactor token provider tests to use a common base class for single active token providers and improve token verification logic --- .../AbpSingleActiveTokenProvider.cs | 13 +- .../AbpChangeEmailTokenProvider_Tests.cs | 139 ++--------------- ...AbpEmailConfirmationTokenProvider_Tests.cs | 145 +++--------------- .../AbpPasswordResetTokenProvider_Tests.cs | 139 ++--------------- .../AbpSingleActiveTokenProviderTestBase.cs | 138 +++++++++++++++++ 5 files changed, 200 insertions(+), 374 deletions(-) create mode 100644 modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProviderTestBase.cs 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 index baf8e4bffd..511b2f251a 100644 --- 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 @@ -62,7 +62,18 @@ public abstract class AbpSingleActiveTokenProvider : DataProtectorTokenProvider< } var inputHash = ComputeSha256Hash(token); - return string.Equals(storedHash, inputHash, StringComparison.Ordinal); + 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) 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 index f72994d582..dee4ebe068 100644 --- 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 @@ -7,22 +7,25 @@ using Xunit; namespace Volo.Abp.Identity.AspNetCore; -public class AbpChangeEmailTokenProvider_Tests : AbpIdentityAspNetCoreTestBase +public class AbpChangeEmailTokenProvider_Tests : AbpSingleActiveTokenProviderTestBase { private const string NewEmail = "newemail@example.com"; - protected IIdentityUserRepository UserRepository { get; } - protected IdentityUserManager UserManager { get; } - protected IdentityTestData TestData { get; } - protected IUnitOfWorkManager UnitOfWorkManager { get; } + protected override Task GenerateTokenAsync(IdentityUser user) + => UserManager.GenerateChangeEmailTokenAsync(user, NewEmail); - public AbpChangeEmailTokenProvider_Tests() - { - UserRepository = GetRequiredService(); - UserManager = GetRequiredService(); - TestData = GetRequiredService(); - UnitOfWorkManager = GetRequiredService(); - } + 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() @@ -42,82 +45,6 @@ public class AbpChangeEmailTokenProvider_Tests : AbpIdentityAspNetCoreTestBase 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() { @@ -133,42 +60,8 @@ public class AbpChangeEmailTokenProvider_Tests : AbpIdentityAspNetCoreTestBase // 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); + (await VerifyTokenAsync(john, token)).ShouldBeFalse(); - 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 index b285a5e6d7..fe8d5c7de8 100644 --- 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 @@ -7,20 +7,23 @@ using Xunit; namespace Volo.Abp.Identity.AspNetCore; -public class AbpEmailConfirmationTokenProvider_Tests : AbpIdentityAspNetCoreTestBase +public class AbpEmailConfirmationTokenProvider_Tests : AbpSingleActiveTokenProviderTestBase { - protected IIdentityUserRepository UserRepository { get; } - protected IdentityUserManager UserManager { get; } - protected IdentityTestData TestData { get; } - protected IUnitOfWorkManager UnitOfWorkManager { get; } + protected override Task GenerateTokenAsync(IdentityUser user) + => UserManager.GenerateEmailConfirmationTokenAsync(user); - public AbpEmailConfirmationTokenProvider_Tests() - { - UserRepository = GetRequiredService(); - UserManager = GetRequiredService(); - TestData = GetRequiredService(); - UnitOfWorkManager = GetRequiredService(); - } + 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() @@ -40,82 +43,6 @@ public class AbpEmailConfirmationTokenProvider_Tests : AbpIdentityAspNetCoreTest 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() { @@ -129,49 +56,13 @@ public class AbpEmailConfirmationTokenProvider_Tests : AbpIdentityAspNetCoreTest 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. + // automatically invalidated. Callers must explicitly revoke the hash. john = await UserRepository.GetAsync(TestData.UserJohnId); - var removeResult = await UserManager.RemoveEmailConfirmationTokenAsync(john); - removeResult.Succeeded.ShouldBeTrue(); + (await UserManager.RemoveEmailConfirmationTokenAsync(john)).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); + (await VerifyTokenAsync(john, token)).ShouldBeFalse(); - 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 index d0eec0ca94..870829a975 100644 --- 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 @@ -7,20 +7,23 @@ using Xunit; namespace Volo.Abp.Identity.AspNetCore; -public class AbpPasswordResetTokenProvider_Tests : AbpIdentityAspNetCoreTestBase +public class AbpPasswordResetTokenProvider_Tests : AbpSingleActiveTokenProviderTestBase { - protected IIdentityUserRepository UserRepository { get; } - protected IdentityUserManager UserManager { get; } - protected IdentityTestData TestData { get; } - protected IUnitOfWorkManager UnitOfWorkManager { get; } + protected override Task GenerateTokenAsync(IdentityUser user) + => UserManager.GeneratePasswordResetTokenAsync(user); - public AbpPasswordResetTokenProvider_Tests() - { - UserRepository = GetRequiredService(); - UserManager = GetRequiredService(); - TestData = GetRequiredService(); - UnitOfWorkManager = GetRequiredService(); - } + 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() @@ -40,82 +43,6 @@ public class AbpPasswordResetTokenProvider_Tests : AbpIdentityAspNetCoreTestBase 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() { @@ -131,42 +58,8 @@ public class AbpPasswordResetTokenProvider_Tests : AbpIdentityAspNetCoreTestBase // 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); + (await VerifyTokenAsync(john, token)).ShouldBeFalse(); - isValid.ShouldBeTrue(); 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..37f9aec016 --- /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 GetHashKey() => GetPurpose() + AbpSingleActiveTokenProvider.TokenHashSuffix; + + [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, GetProviderName(), GetHashKey(), "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(); + } + } +} From 38ef51cec5648ae93f7877fea81546f189e9b9a6 Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 24 Feb 2026 09:10:22 +0800 Subject: [PATCH 3/3] Refactor token providers to include cancellation token support and update token handling logic --- .../AspNetCore/AbpChangeEmailTokenProvider.cs | 6 +++-- .../AbpEmailConfirmationTokenProvider.cs | 6 +++-- .../AbpPasswordResetTokenProvider.cs | 6 +++-- .../AbpSingleActiveTokenProvider.cs | 22 ++++++++++++++----- ...yUserManagerSingleActiveTokenExtensions.cs | 14 ++++++------ .../AbpSingleActiveTokenProviderTestBase.cs | 4 ++-- ...anagerSingleActiveTokenExtensions_Tests.cs | 12 +++++----- 7 files changed, 43 insertions(+), 27 deletions(-) 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 index c938b6db67..344c188264 100644 --- 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 @@ -3,6 +3,7 @@ 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; @@ -18,8 +19,9 @@ public class AbpChangeEmailTokenProvider : AbpSingleActiveTokenProvider IDataProtectionProvider dataProtectionProvider, IOptions options, ILogger> logger, - IIdentityUserRepository userRepository) - : base(dataProtectionProvider, options, logger, userRepository) + IIdentityUserRepository userRepository, + ICancellationTokenProvider cancellationTokenProvider) + : base(dataProtectionProvider, options, logger, userRepository, cancellationTokenProvider) { } } 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 index 2b758f1fc2..1f99543f8f 100644 --- 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 @@ -3,6 +3,7 @@ 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; @@ -31,8 +32,9 @@ public class AbpEmailConfirmationTokenProvider : AbpSingleActiveTokenProvider IDataProtectionProvider dataProtectionProvider, IOptions options, ILogger> logger, - IIdentityUserRepository userRepository) - : base(dataProtectionProvider, options, logger, userRepository) + IIdentityUserRepository userRepository, + ICancellationTokenProvider cancellationTokenProvider) + : base(dataProtectionProvider, options, logger, userRepository, cancellationTokenProvider) { } } 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 index 95736bbffc..cc3c960804 100644 --- 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 @@ -3,6 +3,7 @@ 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; @@ -18,8 +19,9 @@ public class AbpPasswordResetTokenProvider : AbpSingleActiveTokenProvider IDataProtectionProvider dataProtectionProvider, IOptions options, ILogger> logger, - IIdentityUserRepository userRepository) - : base(dataProtectionProvider, options, logger, userRepository) + IIdentityUserRepository userRepository, + ICancellationTokenProvider cancellationTokenProvider) + : base(dataProtectionProvider, options, logger, userRepository, cancellationTokenProvider) { } } 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 index 511b2f251a..aa6454085c 100644 --- 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 @@ -8,6 +8,7 @@ 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; @@ -19,27 +20,36 @@ namespace Volo.Abp.Identity.AspNetCore; /// public abstract class AbpSingleActiveTokenProvider : DataProtectorTokenProvider { - public const string TokenHashSuffix = "_TokenHash"; + /// + /// 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) + 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); + await UserRepository.EnsureCollectionLoadedAsync(user, u => u.Tokens, CancellationTokenProvider.Token); var tokenHash = ComputeSha256Hash(token); - user.SetToken(Options.Name, purpose + TokenHashSuffix, tokenHash); + user.SetToken(InternalLoginProvider, Options.Name + ":" + purpose, tokenHash); await manager.UpdateAsync(user); @@ -53,9 +63,9 @@ public abstract class AbpSingleActiveTokenProvider : DataProtectorTokenProvider< return false; } - await UserRepository.EnsureCollectionLoadedAsync(user, u => u.Tokens); + await UserRepository.EnsureCollectionLoadedAsync(user, u => u.Tokens, CancellationTokenProvider.Token); - var storedHash = user.FindToken(Options.Name, purpose + TokenHashSuffix)?.Value; + var storedHash = user.FindToken(InternalLoginProvider, Options.Name + ":" + purpose)?.Value; if (storedHash == null) { return false; 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 index 5fa2ce89b3..5b721241fd 100644 --- 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 @@ -7,7 +7,7 @@ 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 { @@ -17,8 +17,8 @@ public static class IdentityUserManagerSingleActiveTokenExtensions /// public static Task RemovePasswordResetTokenAsync(this IdentityUserManager manager, IdentityUser user) { - var name = UserManager.ResetPasswordTokenPurpose + AbpSingleActiveTokenProvider.TokenHashSuffix; - return manager.RemoveAuthenticationTokenAsync(user, manager.Options.Tokens.PasswordResetTokenProvider, name); + var name = manager.Options.Tokens.PasswordResetTokenProvider + ":" + UserManager.ResetPasswordTokenPurpose; + return manager.RemoveAuthenticationTokenAsync(user, AbpSingleActiveTokenProvider.InternalLoginProvider, name); } /// @@ -27,8 +27,8 @@ public static class IdentityUserManagerSingleActiveTokenExtensions /// public static Task RemoveEmailConfirmationTokenAsync(this IdentityUserManager manager, IdentityUser user) { - var name = UserManager.ConfirmEmailTokenPurpose + AbpSingleActiveTokenProvider.TokenHashSuffix; - return manager.RemoveAuthenticationTokenAsync(user, manager.Options.Tokens.EmailConfirmationTokenProvider, name); + var name = manager.Options.Tokens.EmailConfirmationTokenProvider + ":" + UserManager.ConfirmEmailTokenPurpose; + return manager.RemoveAuthenticationTokenAsync(user, AbpSingleActiveTokenProvider.InternalLoginProvider, name); } /// @@ -37,7 +37,7 @@ public static class IdentityUserManagerSingleActiveTokenExtensions /// 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); + 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/AbpSingleActiveTokenProviderTestBase.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProviderTestBase.cs index 37f9aec016..4f42391a05 100644 --- 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 @@ -39,7 +39,7 @@ public abstract class AbpSingleActiveTokenProviderTestBase : AbpIdentityAspNetCo /// Returns the token purpose used as the hash key prefix. protected abstract string GetPurpose(); - private string GetHashKey() => GetPurpose() + AbpSingleActiveTokenProvider.TokenHashSuffix; + private string GetTokenHashName() => GetProviderName() + ":" + GetPurpose(); [Fact] public async Task Generate_And_Verify_Token_Should_Succeed() @@ -103,7 +103,7 @@ public abstract class AbpSingleActiveTokenProviderTestBase : AbpIdentityAspNetCo // Overwrite with a non-hex string to simulate data corruption. user = await UserRepository.GetAsync(TestData.UserJohnId); - await UserManager.SetAuthenticationTokenAsync(user, GetProviderName(), GetHashKey(), "not-valid-hex!!!"); + await UserManager.SetAuthenticationTokenAsync(user, AbpSingleActiveTokenProvider.InternalLoginProvider, GetTokenHashName(), "not-valid-hex!!!"); user = await UserRepository.GetAsync(TestData.UserJohnId); 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 index 93b06d0019..10beee48dd 100644 --- 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 @@ -30,8 +30,8 @@ public class IdentityUserManagerSingleActiveTokenExtensions_Tests : AbpIdentityA using (var uow = UnitOfWorkManager.Begin()) { var user = await UserRepository.GetAsync(TestData.UserJohnId); - var providerName = UserManager.Options.Tokens.PasswordResetTokenProvider; - var tokenKey = UserManager.ResetPasswordTokenPurpose + AbpSingleActiveTokenProvider.TokenHashSuffix; + 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(); @@ -51,8 +51,8 @@ public class IdentityUserManagerSingleActiveTokenExtensions_Tests : AbpIdentityA using (var uow = UnitOfWorkManager.Begin()) { var user = await UserRepository.GetAsync(TestData.UserJohnId); - var providerName = UserManager.Options.Tokens.EmailConfirmationTokenProvider; - var tokenKey = UserManager.ConfirmEmailTokenPurpose + AbpSingleActiveTokenProvider.TokenHashSuffix; + 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(); @@ -72,8 +72,8 @@ public class IdentityUserManagerSingleActiveTokenExtensions_Tests : AbpIdentityA 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; + 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();