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