From 71b52a8ee2917d2d7238f687cd01fd5039c8731e Mon Sep 17 00:00:00 2001 From: maliming Date: Mon, 23 Feb 2026 19:03:32 +0800 Subject: [PATCH] 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(); + } + } +}