diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailTwoFactorTokenProvider.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailTwoFactorTokenProvider.cs new file mode 100644 index 0000000000..45e3e8eccb --- /dev/null +++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailTwoFactorTokenProvider.cs @@ -0,0 +1,36 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Volo.Abp.Identity; +using Volo.Abp.Threading; +using Volo.Abp.Timing; + +namespace Volo.Abp.Identity.AspNetCore; + +/// +/// Single-use email 2FA code provider; replaces the ASP.NET Core Identity TOTP-based +/// EmailTokenProvider<TUser> under . +/// +public class AbpEmailTwoFactorTokenProvider : AbpTwoFactorTokenProvider +{ + public const string ProviderName = "AbpEmailTwoFactor"; + + public override string Name => ProviderName; + + public AbpEmailTwoFactorTokenProvider( + IOptions options, + IIdentityUserRepository userRepository, + ICancellationTokenProvider cancellationTokenProvider, + IClock clock, + IDataProtectionProvider dataProtectionProvider) + : base(options.Value, userRepository, cancellationTokenProvider, clock, dataProtectionProvider) + { + } + + public override async Task CanGenerateTwoFactorTokenAsync(UserManager manager, IdentityUser user) + { + var email = await manager.GetEmailAsync(user); + return !string.IsNullOrWhiteSpace(email) && await manager.IsEmailConfirmedAsync(user); + } +} diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailTwoFactorTokenProviderOptions.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailTwoFactorTokenProviderOptions.cs new file mode 100644 index 0000000000..6ec9e96e1a --- /dev/null +++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailTwoFactorTokenProviderOptions.cs @@ -0,0 +1,5 @@ +namespace Volo.Abp.Identity.AspNetCore; + +public class AbpEmailTwoFactorTokenProviderOptions : AbpTwoFactorTokenProviderOptions +{ +} 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 3284a31601..eb8a1a7c54 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 @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; @@ -23,6 +23,8 @@ public class AbpIdentityAspNetCoreModule : AbpModule .AddTokenProvider(AbpPasswordResetTokenProvider.ProviderName) .AddTokenProvider(AbpEmailConfirmationTokenProvider.ProviderName) .AddTokenProvider(AbpChangeEmailTokenProvider.ProviderName) + .AddTokenProvider(TokenOptions.DefaultEmailProvider) + .AddTokenProvider(TokenOptions.DefaultPhoneProvider) .AddSignInManager() .AddUserValidator(); }); diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPhoneNumberTwoFactorTokenProvider.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPhoneNumberTwoFactorTokenProvider.cs new file mode 100644 index 0000000000..068e94cb20 --- /dev/null +++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPhoneNumberTwoFactorTokenProvider.cs @@ -0,0 +1,36 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Volo.Abp.Identity; +using Volo.Abp.Threading; +using Volo.Abp.Timing; + +namespace Volo.Abp.Identity.AspNetCore; + +/// +/// Single-use phone 2FA code provider; replaces the ASP.NET Core Identity TOTP-based +/// PhoneNumberTokenProvider<TUser> under . +/// +public class AbpPhoneNumberTwoFactorTokenProvider : AbpTwoFactorTokenProvider +{ + public const string ProviderName = "AbpPhoneNumberTwoFactor"; + + public override string Name => ProviderName; + + public AbpPhoneNumberTwoFactorTokenProvider( + IOptions options, + IIdentityUserRepository userRepository, + ICancellationTokenProvider cancellationTokenProvider, + IClock clock, + IDataProtectionProvider dataProtectionProvider) + : base(options.Value, userRepository, cancellationTokenProvider, clock, dataProtectionProvider) + { + } + + public override async Task CanGenerateTwoFactorTokenAsync(UserManager manager, IdentityUser user) + { + var phoneNumber = await manager.GetPhoneNumberAsync(user); + return !string.IsNullOrWhiteSpace(phoneNumber) && await manager.IsPhoneNumberConfirmedAsync(user); + } +} diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPhoneNumberTwoFactorTokenProviderOptions.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPhoneNumberTwoFactorTokenProviderOptions.cs new file mode 100644 index 0000000000..a477ecc594 --- /dev/null +++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPhoneNumberTwoFactorTokenProviderOptions.cs @@ -0,0 +1,5 @@ +namespace Volo.Abp.Identity.AspNetCore; + +public class AbpPhoneNumberTwoFactorTokenProviderOptions : AbpTwoFactorTokenProviderOptions +{ +} 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 aa6454085c..835b52b3b0 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 @@ -51,7 +51,7 @@ public abstract class AbpSingleActiveTokenProvider : DataProtectorTokenProvider< var tokenHash = ComputeSha256Hash(token); user.SetToken(InternalLoginProvider, Options.Name + ":" + purpose, tokenHash); - await manager.UpdateAsync(user); + (await manager.UpdateAsync(user)).CheckErrors(); return token; } diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpTwoFactorTokenProvider.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpTwoFactorTokenProvider.cs new file mode 100644 index 0000000000..11fabf3c6b --- /dev/null +++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpTwoFactorTokenProvider.cs @@ -0,0 +1,224 @@ +using System; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Identity; +using Volo.Abp.Threading; +using Volo.Abp.Timing; + +namespace Volo.Abp.Identity.AspNetCore; + +/// +/// Base class for ABP two-factor verification code providers (e.g. Email, Phone). +/// Generates a numeric OTP, stores it encrypted under +/// with an absolute UTC expiration, and removes the stored entry on successful +/// validation (single-use). Expected to be combined with Identity lockout for +/// rate-limiting. +/// +public abstract class AbpTwoFactorTokenProvider : IUserTwoFactorTokenProvider +{ + public const string InternalLoginProvider = "[AbpTwoFactorToken]"; + + protected const char StoredValueSeparator = '|'; + + protected const string DataProtectionPurposeRoot = "Volo.Abp.Identity.AspNetCore.AbpTwoFactorTokenProvider"; + + protected AbpTwoFactorTokenProviderOptions Options { get; } + + protected IIdentityUserRepository UserRepository { get; } + + protected ICancellationTokenProvider CancellationTokenProvider { get; } + + protected IClock Clock { get; } + + protected IDataProtectionProvider DataProtectionProvider { get; } + + /// Unique provider name; used as the stored-token key prefix and DataProtection purpose segment. + public abstract string Name { get; } + + protected AbpTwoFactorTokenProvider( + AbpTwoFactorTokenProviderOptions options, + IIdentityUserRepository userRepository, + ICancellationTokenProvider cancellationTokenProvider, + IClock clock, + IDataProtectionProvider dataProtectionProvider) + { + Options = options; + UserRepository = userRepository; + CancellationTokenProvider = cancellationTokenProvider; + Clock = clock; + DataProtectionProvider = dataProtectionProvider; + } + + public abstract Task CanGenerateTwoFactorTokenAsync(UserManager manager, IdentityUser user); + + public virtual async Task GenerateAsync(string purpose, UserManager manager, IdentityUser user) + { + var code = GenerateNumericCode(Options.CodeLength); + var protectedCode = CreateProtector(purpose).Protect(code); + var expiresAtUnixSeconds = ToUnixSeconds(Clock.Now.Add(Options.TokenLifespan)); + var storedValue = protectedCode + StoredValueSeparator + expiresAtUnixSeconds.ToString(CultureInfo.InvariantCulture); + + await UserRepository.EnsureCollectionLoadedAsync(user, u => u.Tokens, CancellationTokenProvider.Token); + user.SetToken(InternalLoginProvider, GetTokenName(purpose), storedValue); + + (await manager.UpdateAsync(user)).CheckErrors(); + + return code; + } + + public virtual async Task ValidateAsync(string purpose, string token, UserManager manager, IdentityUser user) + { + if (string.IsNullOrEmpty(token)) + { + return false; + } + + await UserRepository.EnsureCollectionLoadedAsync(user, u => u.Tokens, CancellationTokenProvider.Token); + + var tokenName = GetTokenName(purpose); + var stored = user.FindToken(InternalLoginProvider, tokenName)?.Value; + if (stored == null) + { + return false; + } + + if (!TryParseStoredValue(stored, out var protectedCode, out var expiresAtUnixSeconds)) + { + await TryRemoveStoredTokenAsync(manager, user, tokenName); + return false; + } + + if (ToUnixSeconds(Clock.Now) >= expiresAtUnixSeconds) + { + await TryRemoveStoredTokenAsync(manager, user, tokenName); + return false; + } + + string storedCode; + try + { + storedCode = CreateProtector(purpose).Unprotect(protectedCode); + } + catch (CryptographicException) + { + await TryRemoveStoredTokenAsync(manager, user, tokenName); + return false; + } + catch (FormatException) + { + await TryRemoveStoredTokenAsync(manager, user, tokenName); + return false; + } + + var storedBytes = Encoding.UTF8.GetBytes(storedCode); + var inputBytes = Encoding.UTF8.GetBytes(token); + if (storedBytes.Length != inputBytes.Length || + !CryptographicOperations.FixedTimeEquals(storedBytes, inputBytes)) + { + // Keep stored entry so the user can retry until expiration. Callers must rate-limit. + return false; + } + + // Translate ConcurrencyStamp failure (another request won the consume race) to false, + // so legitimate concurrent verification doesn't surface as a 500. + try + { + await RemoveStoredTokenAsync(manager, user, tokenName); + } + catch (AbpIdentityResultException) + { + return false; + } + + return true; + } + + /// + /// Removes the stored entry. Throws on persistence + /// failure so the successful-verification path knows the consume actually committed. + /// Cleanup paths use instead. + /// + protected virtual async Task RemoveStoredTokenAsync(UserManager manager, IdentityUser user, string tokenName) + { + user.RemoveToken(InternalLoginProvider, tokenName); + (await manager.UpdateAsync(user)).CheckErrors(); + } + + /// + /// Cleanup variant that swallows concurrent-update failures. The next + /// will overwrite whatever remains. + /// + protected virtual async Task TryRemoveStoredTokenAsync(UserManager manager, IdentityUser user, string tokenName) + { + try + { + await RemoveStoredTokenAsync(manager, user, tokenName); + } + catch (AbpIdentityResultException) + { + } + } + + protected virtual string GetTokenName(string purpose) + { + return Name + ":" + purpose; + } + + protected virtual IDataProtector CreateProtector(string purpose) + { + return DataProtectionProvider.CreateProtector(DataProtectionPurposeRoot, Name, purpose); + } + + protected virtual string GenerateNumericCode(int length) + { + if (length is <= 0 or > 9) + { + // Cap at 9 to stay comfortably within Int32 range. + throw new ArgumentOutOfRangeException(nameof(length), length, "Code length must be between 1 and 9."); + } + + var upperBound = (int)Math.Pow(10, length); + var value = RandomNumberGenerator.GetInt32(0, upperBound); + return value.ToString(new string('0', length), CultureInfo.InvariantCulture); + } + + protected virtual long ToUnixSeconds(DateTime moment) + { + // Treat Unspecified as local time to match IClock.Now's default behaviour. + if (moment.Kind == DateTimeKind.Unspecified) + { + moment = DateTime.SpecifyKind(moment, DateTimeKind.Local); + } + + return new DateTimeOffset(moment).ToUnixTimeSeconds(); + } + + private static bool TryParseStoredValue(string stored, out string protectedCode, out long expiresAtUnixSeconds) + { + protectedCode = string.Empty; + expiresAtUnixSeconds = 0; + + var separatorIndex = stored.LastIndexOf(StoredValueSeparator); + if (separatorIndex <= 0 || separatorIndex == stored.Length - 1) + { + return false; + } + + var protectedPart = stored.Substring(0, separatorIndex); + var secondsPart = stored.Substring(separatorIndex + 1); + + if (!long.TryParse(secondsPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)) + { + return false; + } + + protectedCode = protectedPart; + expiresAtUnixSeconds = seconds; + return true; + } +} diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpTwoFactorTokenProviderOptions.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpTwoFactorTokenProviderOptions.cs new file mode 100644 index 0000000000..973d677091 --- /dev/null +++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpTwoFactorTokenProviderOptions.cs @@ -0,0 +1,12 @@ +using System; + +namespace Volo.Abp.Identity.AspNetCore; + +public abstract class AbpTwoFactorTokenProviderOptions +{ + /// Default: 3 minutes. + public TimeSpan TokenLifespan { get; set; } = TimeSpan.FromMinutes(3); + + /// Default: 6. Valid range: 1 to 9 (inclusive). + public int CodeLength { get; set; } = 6; +} diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpEmailTwoFactorTokenProvider_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpEmailTwoFactorTokenProvider_Tests.cs new file mode 100644 index 0000000000..a967bdcd5b --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpEmailTwoFactorTokenProvider_Tests.cs @@ -0,0 +1,236 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Shouldly; +using Volo.Abp.Threading; +using Volo.Abp.Timing; +using Volo.Abp.Uow; +using Xunit; + +namespace Volo.Abp.Identity.AspNetCore; + +public class AbpEmailTwoFactorTokenProvider_Tests : AbpTwoFactorTokenProviderTestBase +{ + protected override string GetTokenProviderName() => TokenOptions.DefaultEmailProvider; + + protected override string GetInternalProviderName() => AbpEmailTwoFactorTokenProvider.ProviderName; + + [Fact] + public void AbpEmailTwoFactorTokenProvider_Should_Replace_Default_Email_Provider() + { + var identityOptions = GetRequiredService>().Value; + + identityOptions.Tokens.ProviderMap.ShouldContainKey(TokenOptions.DefaultEmailProvider); + identityOptions.Tokens.ProviderMap[TokenOptions.DefaultEmailProvider].ProviderType + .ShouldBe(typeof(AbpEmailTwoFactorTokenProvider)); + } + + [Fact] + public void Default_Options_Should_Match_Documented_Defaults() + { + var options = GetRequiredService>().Value; + + options.CodeLength.ShouldBe(6); + options.TokenLifespan.ShouldBe(TimeSpan.FromMinutes(3)); + } + + [Fact] + public async Task CanGenerateTwoFactorTokenAsync_Should_Require_Confirmed_Email() + { + using var uow = UnitOfWorkManager.Begin(); + + var provider = GetRequiredService(); + var john = await UserRepository.GetAsync(TestData.UserJohnId); + + // john.EmailConfirmed defaults to false in the test seed. + (await provider.CanGenerateTwoFactorTokenAsync(UserManager, john)).ShouldBeFalse(); + + john.SetEmailConfirmed(true); + (await UserManager.UpdateAsync(john)).Succeeded.ShouldBeTrue(); + + john = await UserRepository.GetAsync(TestData.UserJohnId); + (await provider.CanGenerateTwoFactorTokenAsync(UserManager, john)).ShouldBeTrue(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Email_And_Phone_Tokens_For_Same_User_Should_Be_Independent() + { + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var emailCode = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultEmailProvider, TwoFactorPurpose); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + var phoneCode = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultPhoneProvider, TwoFactorPurpose); + + // Consuming the email code must not invalidate the phone code. + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultEmailProvider, TwoFactorPurpose, emailCode)) + .ShouldBeTrue(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultPhoneProvider, TwoFactorPurpose, phoneCode)) + .ShouldBeTrue(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Email_Code_Should_Survive_Security_Stamp_Change() + { + // Unlike the default DataProtector-backed token providers, this 2FA provider does + // not bind the code to the user's SecurityStamp, so rotating the stamp (e.g. a + // concurrent password change) must NOT invalidate an outstanding 2FA code. + // This keeps the login flow resilient to legitimate state changes happening + // between the credential step and the verification step. + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var code = await GenerateTokenAsync(user); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.UpdateSecurityStampAsync(user)).Succeeded.ShouldBeTrue(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, code)).ShouldBeTrue(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Custom_CodeLength_Should_Produce_Code_Of_That_Length() + { + using var uow = UnitOfWorkManager.Begin(); + + var customProvider = new AbpEmailTwoFactorTokenProvider( + Microsoft.Extensions.Options.Options.Create(new AbpEmailTwoFactorTokenProviderOptions { CodeLength = 8 }), + UserRepository, + GetRequiredService(), + Clock, + GetRequiredService()); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var code = await customProvider.GenerateAsync(TwoFactorPurpose, UserManager, user); + + code.Length.ShouldBe(8); + code.ShouldMatch(@"^\d{8}$"); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await customProvider.ValidateAsync(TwoFactorPurpose, code, UserManager, user)).ShouldBeTrue(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Custom_TokenLifespan_Should_Be_Honored() + { + using var uow = UnitOfWorkManager.Begin(); + + var customLifespan = TimeSpan.FromMinutes(30); + + var customProvider = new AbpEmailTwoFactorTokenProvider( + Microsoft.Extensions.Options.Options.Create(new AbpEmailTwoFactorTokenProviderOptions { TokenLifespan = customLifespan }), + UserRepository, + GetRequiredService(), + Clock, + GetRequiredService()); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var beforeGenerate = new DateTimeOffset(Clock.Now.Kind == DateTimeKind.Unspecified + ? DateTime.SpecifyKind(Clock.Now, DateTimeKind.Local) + : Clock.Now).ToUnixTimeSeconds(); + await customProvider.GenerateAsync(TwoFactorPurpose, UserManager, user); + var afterGenerate = new DateTimeOffset(Clock.Now.Kind == DateTimeKind.Unspecified + ? DateTime.SpecifyKind(Clock.Now, DateTimeKind.Local) + : Clock.Now).ToUnixTimeSeconds(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + var stored = await UserManager.GetAuthenticationTokenAsync( + user, + AbpTwoFactorTokenProvider.InternalLoginProvider, + AbpEmailTwoFactorTokenProvider.ProviderName + ":" + TwoFactorPurpose); + stored.ShouldNotBeNull(); + + var separator = stored.LastIndexOf('|'); + separator.ShouldBeGreaterThan(0); + var secondsPart = stored.Substring(separator + 1); + long.TryParse(secondsPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out var storedExpiresUnix) + .ShouldBeTrue(); + + storedExpiresUnix.ShouldBeGreaterThanOrEqualTo(beforeGenerate + (long)customLifespan.TotalSeconds); + storedExpiresUnix.ShouldBeLessThanOrEqualTo(afterGenerate + (long)customLifespan.TotalSeconds); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Direct_Provider_Generate_And_Validate_Should_Round_Trip() + { + using var uow = UnitOfWorkManager.Begin(); + + var provider = GetRequiredService(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var code = await provider.GenerateAsync(TwoFactorPurpose, UserManager, user); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await provider.ValidateAsync(TwoFactorPurpose, code, UserManager, user)).ShouldBeTrue(); + + // Consumed by the previous successful validation. + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await provider.ValidateAsync(TwoFactorPurpose, code, UserManager, user)).ShouldBeFalse(); + + await uow.CompleteAsync(); + } + + [Fact] + public void Provider_Name_Should_Be_The_Published_Constant() + { + var provider = GetRequiredService(); + + provider.Name.ShouldBe(AbpEmailTwoFactorTokenProvider.ProviderName); + AbpEmailTwoFactorTokenProvider.ProviderName.ShouldBe("AbpEmailTwoFactor"); + } + + [Fact] + public async Task Unprotectable_Stored_Payload_From_Wrong_Protector_Should_Fail_And_Cleanup() + { + // Protect a code under a DIFFERENT DataProtection purpose chain to simulate data + // carried over from another module/provider. ValidateAsync must refuse it (Unprotect + // throws) and clean up the stored entry. + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var code = await GenerateTokenAsync(user); + var tokenName = GetTokenName(TwoFactorPurpose); + + var dpp = GetRequiredService(); + var wrongProtector = dpp.CreateProtector("some-unrelated-purpose"); + var wrongPayload = wrongProtector.Protect("123456"); + + var futureSeconds = new DateTimeOffset(Clock.Now.Kind == DateTimeKind.Unspecified + ? DateTime.SpecifyKind(Clock.Now.AddMinutes(1), DateTimeKind.Local) + : Clock.Now.AddMinutes(1)) + .ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.SetAuthenticationTokenAsync( + user, AbpTwoFactorTokenProvider.InternalLoginProvider, tokenName, wrongPayload + "|" + futureSeconds)) + .Succeeded.ShouldBeTrue(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, code)).ShouldBeFalse(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.GetAuthenticationTokenAsync( + user, AbpTwoFactorTokenProvider.InternalLoginProvider, tokenName)) + .ShouldBeNull(); + + await uow.CompleteAsync(); + } +} diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpPhoneNumberTwoFactorTokenProvider_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpPhoneNumberTwoFactorTokenProvider_Tests.cs new file mode 100644 index 0000000000..c56c31ffd6 --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpPhoneNumberTwoFactorTokenProvider_Tests.cs @@ -0,0 +1,223 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Shouldly; +using Volo.Abp.Threading; +using Volo.Abp.Timing; +using Volo.Abp.Uow; +using Xunit; + +namespace Volo.Abp.Identity.AspNetCore; + +public class AbpPhoneNumberTwoFactorTokenProvider_Tests : AbpTwoFactorTokenProviderTestBase +{ + protected override string GetTokenProviderName() => TokenOptions.DefaultPhoneProvider; + + protected override string GetInternalProviderName() => AbpPhoneNumberTwoFactorTokenProvider.ProviderName; + + [Fact] + public void AbpPhoneNumberTwoFactorTokenProvider_Should_Replace_Default_Phone_Provider() + { + var identityOptions = GetRequiredService>().Value; + + identityOptions.Tokens.ProviderMap.ShouldContainKey(TokenOptions.DefaultPhoneProvider); + identityOptions.Tokens.ProviderMap[TokenOptions.DefaultPhoneProvider].ProviderType + .ShouldBe(typeof(AbpPhoneNumberTwoFactorTokenProvider)); + } + + [Fact] + public void Default_Options_Should_Match_Documented_Defaults() + { + var options = GetRequiredService>().Value; + + options.CodeLength.ShouldBe(6); + options.TokenLifespan.ShouldBe(TimeSpan.FromMinutes(3)); + } + + [Fact] + public async Task CanGenerateTwoFactorTokenAsync_Should_Require_Confirmed_Phone_Number() + { + using var uow = UnitOfWorkManager.Begin(); + + var provider = GetRequiredService(); + var john = await UserRepository.GetAsync(TestData.UserJohnId); + + // No phone number set by the seed. + (await provider.CanGenerateTwoFactorTokenAsync(UserManager, john)).ShouldBeFalse(); + + (await UserManager.SetPhoneNumberAsync(john, "+1-555-0100")).Succeeded.ShouldBeTrue(); + + john = await UserRepository.GetAsync(TestData.UserJohnId); + // Phone present but not confirmed yet. + (await provider.CanGenerateTwoFactorTokenAsync(UserManager, john)).ShouldBeFalse(); + + john.SetPhoneNumberConfirmed(true); + (await UserManager.UpdateAsync(john)).Succeeded.ShouldBeTrue(); + + john = await UserRepository.GetAsync(TestData.UserJohnId); + (await provider.CanGenerateTwoFactorTokenAsync(UserManager, john)).ShouldBeTrue(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task ChangePhoneNumber_Token_Should_Be_Single_Use() + { + // IdentityOptions.Tokens.ChangePhoneNumberTokenProvider defaults to the same + // "Phone" provider name, so GenerateChangePhoneNumberTokenAsync now routes + // through AbpPhoneNumberTwoFactorTokenProvider and inherits single-use semantics. + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + const string newPhone = "+1-555-0199"; + var token = await UserManager.GenerateChangePhoneNumberTokenAsync(user, newPhone); + + token.ShouldNotBeNullOrEmpty(); + token.Length.ShouldBe(6); + token.ShouldMatch(@"^\d{6}$"); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.VerifyChangePhoneNumberTokenAsync(user, token, newPhone)).ShouldBeTrue(); + + // Single-use: second verification must fail. + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.VerifyChangePhoneNumberTokenAsync(user, token, newPhone)).ShouldBeFalse(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task ChangePhoneNumber_Token_Should_Be_Bound_To_The_Target_Phone_Number() + { + // Purpose is "ChangePhoneNumber:{phoneNumber}" so a token issued for one + // target phone number must not validate against a different one. + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var token = await UserManager.GenerateChangePhoneNumberTokenAsync(user, "+1-555-0111"); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.VerifyChangePhoneNumberTokenAsync(user, token, "+1-555-0222")).ShouldBeFalse(); + + // Original target still works. + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.VerifyChangePhoneNumberTokenAsync(user, token, "+1-555-0111")).ShouldBeTrue(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Phone_Code_Should_Survive_Security_Stamp_Change() + { + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var code = await GenerateTokenAsync(user); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.UpdateSecurityStampAsync(user)).Succeeded.ShouldBeTrue(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, code)).ShouldBeTrue(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Custom_CodeLength_Should_Produce_Code_Of_That_Length() + { + using var uow = UnitOfWorkManager.Begin(); + + var customProvider = new AbpPhoneNumberTwoFactorTokenProvider( + Microsoft.Extensions.Options.Options.Create(new AbpPhoneNumberTwoFactorTokenProviderOptions { CodeLength = 4 }), + UserRepository, + GetRequiredService(), + Clock, + GetRequiredService()); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var code = await customProvider.GenerateAsync(TwoFactorPurpose, UserManager, user); + + code.Length.ShouldBe(4); + code.ShouldMatch(@"^\d{4}$"); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await customProvider.ValidateAsync(TwoFactorPurpose, code, UserManager, user)).ShouldBeTrue(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Custom_TokenLifespan_Should_Be_Honored() + { + using var uow = UnitOfWorkManager.Begin(); + + var customLifespan = TimeSpan.FromMinutes(15); + + var customProvider = new AbpPhoneNumberTwoFactorTokenProvider( + Microsoft.Extensions.Options.Options.Create(new AbpPhoneNumberTwoFactorTokenProviderOptions { TokenLifespan = customLifespan }), + UserRepository, + GetRequiredService(), + Clock, + GetRequiredService()); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var beforeGenerate = new DateTimeOffset(Clock.Now.Kind == DateTimeKind.Unspecified + ? DateTime.SpecifyKind(Clock.Now, DateTimeKind.Local) + : Clock.Now).ToUnixTimeSeconds(); + await customProvider.GenerateAsync(TwoFactorPurpose, UserManager, user); + var afterGenerate = new DateTimeOffset(Clock.Now.Kind == DateTimeKind.Unspecified + ? DateTime.SpecifyKind(Clock.Now, DateTimeKind.Local) + : Clock.Now).ToUnixTimeSeconds(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + var stored = await UserManager.GetAuthenticationTokenAsync( + user, + AbpTwoFactorTokenProvider.InternalLoginProvider, + AbpPhoneNumberTwoFactorTokenProvider.ProviderName + ":" + TwoFactorPurpose); + stored.ShouldNotBeNull(); + + var separator = stored.LastIndexOf('|'); + separator.ShouldBeGreaterThan(0); + var secondsPart = stored.Substring(separator + 1); + long.TryParse(secondsPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out var storedExpiresUnix) + .ShouldBeTrue(); + + storedExpiresUnix.ShouldBeGreaterThanOrEqualTo(beforeGenerate + (long)customLifespan.TotalSeconds); + storedExpiresUnix.ShouldBeLessThanOrEqualTo(afterGenerate + (long)customLifespan.TotalSeconds); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Direct_Provider_Generate_And_Validate_Should_Round_Trip() + { + using var uow = UnitOfWorkManager.Begin(); + + var provider = GetRequiredService(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var code = await provider.GenerateAsync(TwoFactorPurpose, UserManager, user); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await provider.ValidateAsync(TwoFactorPurpose, code, UserManager, user)).ShouldBeTrue(); + + // Consumed by the previous successful validation. + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await provider.ValidateAsync(TwoFactorPurpose, code, UserManager, user)).ShouldBeFalse(); + + await uow.CompleteAsync(); + } + + [Fact] + public void Provider_Name_Should_Be_The_Published_Constant() + { + var provider = GetRequiredService(); + + provider.Name.ShouldBe(AbpPhoneNumberTwoFactorTokenProvider.ProviderName); + AbpPhoneNumberTwoFactorTokenProvider.ProviderName.ShouldBe("AbpPhoneNumberTwoFactor"); + } +} diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpTwoFactorTokenProviderTestBase.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpTwoFactorTokenProviderTestBase.cs new file mode 100644 index 0000000000..6f72dee980 --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpTwoFactorTokenProviderTestBase.cs @@ -0,0 +1,612 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Shouldly; +using Volo.Abp.Timing; +using Volo.Abp.Uow; +using Xunit; + +namespace Volo.Abp.Identity.AspNetCore; + +/// +/// Abstract base class that exercises the common behaviour of every +/// subclass. Concrete subclasses wire up +/// the Identity API calls that route to the provider under test. +/// +public abstract class AbpTwoFactorTokenProviderTestBase : AbpIdentityAspNetCoreTestBase +{ + protected const string TwoFactorPurpose = "TwoFactor"; + protected const string OtherPurpose = "SomeOtherPurpose"; + + protected IIdentityUserRepository UserRepository { get; } + protected IdentityUserManager UserManager { get; } + protected IdentityTestData TestData { get; } + protected IUnitOfWorkManager UnitOfWorkManager { get; } + protected IClock Clock { get; } + + protected AbpTwoFactorTokenProviderTestBase() + { + UserRepository = GetRequiredService(); + UserManager = GetRequiredService(); + TestData = GetRequiredService(); + UnitOfWorkManager = GetRequiredService(); + Clock = GetRequiredService(); + } + + /// Identity provider name (e.g. "Email", "Phone") routed to the provider under test. + protected abstract string GetTokenProviderName(); + + /// Internal used as the stored-token key prefix. + protected abstract string GetInternalProviderName(); + + protected virtual Task GenerateTokenAsync(IdentityUser user, string purpose = TwoFactorPurpose) + => UserManager.GenerateUserTokenAsync(user, GetTokenProviderName(), purpose); + + protected virtual Task VerifyTokenAsync(IdentityUser user, string token, string purpose = TwoFactorPurpose) + => UserManager.VerifyUserTokenAsync(user, GetTokenProviderName(), purpose, token); + + protected string GetTokenName(string purpose) => GetInternalProviderName() + ":" + purpose; + + /// Deterministically produce a code that is guaranteed not to equal . + private static string DifferentCode(string code) + { + if (string.IsNullOrEmpty(code)) + { + return "000000"; + } + + var firstChar = code[0]; + var replacement = firstChar == '9' ? '0' : (char)(firstChar + 1); + return replacement + code.Substring(1); + } + + private long ToUnixSeconds(DateTime moment) + { + if (moment.Kind == DateTimeKind.Unspecified) + { + moment = DateTime.SpecifyKind(moment, DateTimeKind.Local); + } + + return new DateTimeOffset(moment).ToUnixTimeSeconds(); + } + + [Fact] + public async Task Generate_Should_Produce_Six_Digit_Numeric_Code() + { + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var code = await GenerateTokenAsync(user); + + code.ShouldNotBeNullOrEmpty(); + code.Length.ShouldBe(6); + code.ShouldMatch(@"^\d{6}$"); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Generate_And_Verify_Token_Should_Succeed() + { + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var code = await GenerateTokenAsync(user); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, code)).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); + var code = await GenerateTokenAsync(user); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, DifferentCode(code))).ShouldBeFalse(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Wrong_Code_Should_Not_Consume_Stored_Entry() + { + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var code = await GenerateTokenAsync(user); + + // A wrong attempt must leave the stored entry intact so the user can retry. + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, DifferentCode(code))).ShouldBeFalse(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, code)).ShouldBeTrue(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Successful_Verification_Should_Consume_The_Code() + { + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var code = await GenerateTokenAsync(user); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, code)).ShouldBeTrue(); + + // Single-use: the same code must not validate twice. + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, code)).ShouldBeFalse(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Second_Token_Generation_Should_Invalidate_First_Token() + { + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var firstCode = await GenerateTokenAsync(user); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + var secondCode = await GenerateTokenAsync(user); + + // Even if first/second codes happen to collide numerically, the first was + // generated against a now-overwritten stored entry, so verifying the FIRST + // code against the NEW stored entry is what counts. We only assert the + // old token no longer validates and the new one does. + user = await UserRepository.GetAsync(TestData.UserJohnId); + if (firstCode != secondCode) + { + (await VerifyTokenAsync(user, firstCode)).ShouldBeFalse(); + user = await UserRepository.GetAsync(TestData.UserJohnId); + } + + (await VerifyTokenAsync(user, secondCode)).ShouldBeTrue(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Expired_Token_Should_Fail_And_Be_Cleaned_Up() + { + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var code = await GenerateTokenAsync(user); + + // Keep the protected payload but rewrite the expiration into the past. + user = await UserRepository.GetAsync(TestData.UserJohnId); + var tokenName = GetTokenName(TwoFactorPurpose); + var stored = await UserManager.GetAuthenticationTokenAsync( + user, AbpTwoFactorTokenProvider.InternalLoginProvider, tokenName); + stored.ShouldNotBeNull(); + + var separator = stored.LastIndexOf('|'); + separator.ShouldBeGreaterThan(0); + var protectedPart = stored.Substring(0, separator); + var expiredValue = protectedPart + "|" + + ToUnixSeconds(Clock.Now.AddMinutes(-1)).ToString(CultureInfo.InvariantCulture); + + (await UserManager.SetAuthenticationTokenAsync( + user, AbpTwoFactorTokenProvider.InternalLoginProvider, tokenName, expiredValue)) + .Succeeded.ShouldBeTrue(); + + // Verification must fail and also wipe the expired entry. + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, code)).ShouldBeFalse(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + var remaining = await UserManager.GetAuthenticationTokenAsync( + user, AbpTwoFactorTokenProvider.InternalLoginProvider, tokenName); + remaining.ShouldBeNull(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Corrupted_Stored_Value_Should_Return_False_Instead_Of_Throwing() + { + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var code = await GenerateTokenAsync(user); + + var tokenName = GetTokenName(TwoFactorPurpose); + + // Overwrite with a malformed value (no separator, garbage chars). + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.SetAuthenticationTokenAsync( + user, AbpTwoFactorTokenProvider.InternalLoginProvider, tokenName, "not-a-valid-stored-value")) + .Succeeded.ShouldBeTrue(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, code)).ShouldBeFalse(); + + // Corrupt entry is cleaned up. + user = await UserRepository.GetAsync(TestData.UserJohnId); + var remaining = await UserManager.GetAuthenticationTokenAsync( + user, AbpTwoFactorTokenProvider.InternalLoginProvider, tokenName); + remaining.ShouldBeNull(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Different_Purposes_Should_Be_Isolated() + { + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var twoFactorCode = await GenerateTokenAsync(user, TwoFactorPurpose); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + var otherCode = await GenerateTokenAsync(user, OtherPurpose); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + // Wrong-purpose validation must fail. + (await VerifyTokenAsync(user, otherCode, TwoFactorPurpose)).ShouldBeFalse(); + (await VerifyTokenAsync(user, twoFactorCode, OtherPurpose)).ShouldBeFalse(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + // Correct purposes still work independently. + (await VerifyTokenAsync(user, twoFactorCode, TwoFactorPurpose)).ShouldBeTrue(); + (await VerifyTokenAsync(user, otherCode, OtherPurpose)).ShouldBeTrue(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Stored_Token_Data_Should_Persist_Across_UnitOfWork_Boundaries() + { + string code; + + using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) + { + var user = await UserRepository.GetAsync(TestData.UserJohnId); + code = await GenerateTokenAsync(user); + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) + { + var user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, code)).ShouldBeTrue(); + await uow.CompleteAsync(); + } + } + + [Fact] + public async Task Verify_Without_Generate_Should_Fail() + { + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, "123456")).ShouldBeFalse(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Empty_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, string.Empty)).ShouldBeFalse(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Different_Users_Should_Be_Isolated() + { + using var uow = UnitOfWorkManager.Begin(); + + var john = await UserRepository.GetAsync(TestData.UserJohnId); + var johnCode = await GenerateTokenAsync(john); + + var david = await UserRepository.GetAsync(TestData.UserDavidId); + var davidCode = await GenerateTokenAsync(david); + + // Neither user's code should validate for the other (assuming distinct codes; + // even if they collide by chance the FixedTimeEquals path still runs, but the + // DataProtection payload is bound to a different user-scoped record). + if (johnCode != davidCode) + { + john = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(john, davidCode)).ShouldBeFalse(); + + david = await UserRepository.GetAsync(TestData.UserDavidId); + (await VerifyTokenAsync(david, johnCode)).ShouldBeFalse(); + } + + // Both still validate against their own user, proving that generating David's + // did not consume John's stored entry (or vice versa). + john = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(john, johnCode)).ShouldBeTrue(); + + david = await UserRepository.GetAsync(TestData.UserDavidId); + (await VerifyTokenAsync(david, davidCode)).ShouldBeTrue(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Multiple_Purposes_Can_Coexist_And_Consume_Independently() + { + using var uow = UnitOfWorkManager.Begin(); + + const string purposeA = "PurposeA"; + const string purposeB = "PurposeB"; + const string purposeC = "PurposeC"; + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var codeA = await GenerateTokenAsync(user, purposeA); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + var codeB = await GenerateTokenAsync(user, purposeB); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + var codeC = await GenerateTokenAsync(user, purposeC); + + // Consume B; A and C must still be valid. + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, codeB, purposeB)).ShouldBeTrue(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, codeA, purposeA)).ShouldBeTrue(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, codeC, purposeC)).ShouldBeTrue(); + + // Each is single-use and now consumed. + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, codeA, purposeA)).ShouldBeFalse(); + (await VerifyTokenAsync(user, codeB, purposeB)).ShouldBeFalse(); + (await VerifyTokenAsync(user, codeC, purposeC)).ShouldBeFalse(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Stored_Value_Missing_Separator_Should_Return_False_And_Cleanup() + { + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var code = await GenerateTokenAsync(user); + var tokenName = GetTokenName(TwoFactorPurpose); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.SetAuthenticationTokenAsync( + user, AbpTwoFactorTokenProvider.InternalLoginProvider, tokenName, "no-separator-here")) + .Succeeded.ShouldBeTrue(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, code)).ShouldBeFalse(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.GetAuthenticationTokenAsync( + user, AbpTwoFactorTokenProvider.InternalLoginProvider, tokenName)) + .ShouldBeNull(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Stored_Value_With_Non_Numeric_Expiration_Should_Return_False_And_Cleanup() + { + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var code = await GenerateTokenAsync(user); + var tokenName = GetTokenName(TwoFactorPurpose); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.SetAuthenticationTokenAsync( + user, AbpTwoFactorTokenProvider.InternalLoginProvider, tokenName, "validlookingpayload|not-a-number")) + .Succeeded.ShouldBeTrue(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, code)).ShouldBeFalse(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.GetAuthenticationTokenAsync( + user, AbpTwoFactorTokenProvider.InternalLoginProvider, tokenName)) + .ShouldBeNull(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Stored_Value_With_Unprotectable_Payload_Should_Return_False_And_Cleanup() + { + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var code = await GenerateTokenAsync(user); + var tokenName = GetTokenName(TwoFactorPurpose); + + var futureSeconds = ToUnixSeconds(Clock.Now.AddMinutes(1)).ToString(CultureInfo.InvariantCulture); + + // Valid expiration, but the protected payload is not a real DataProtection output. + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.SetAuthenticationTokenAsync( + user, AbpTwoFactorTokenProvider.InternalLoginProvider, tokenName, $"not-a-real-protected-payload|{futureSeconds}")) + .Succeeded.ShouldBeTrue(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, code)).ShouldBeFalse(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.GetAuthenticationTokenAsync( + user, AbpTwoFactorTokenProvider.InternalLoginProvider, tokenName)) + .ShouldBeNull(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Generation_Should_Not_Touch_Pre_Existing_Unrelated_Tokens() + { + // John already has a pre-seeded token ("test-provider"/"test-name" = "test-value"). + // Generating a 2FA code must not interfere with it. + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var preExisting = await UserManager.GetAuthenticationTokenAsync(user, "test-provider", "test-name"); + preExisting.ShouldBe("test-value"); + + await GenerateTokenAsync(user); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.GetAuthenticationTokenAsync(user, "test-provider", "test-name")) + .ShouldBe("test-value"); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Successful_Verification_Should_Not_Touch_Pre_Existing_Unrelated_Tokens() + { + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var code = await GenerateTokenAsync(user); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, code)).ShouldBeTrue(); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.GetAuthenticationTokenAsync(user, "test-provider", "test-name")) + .ShouldBe("test-value"); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Token_Should_Be_Stored_Under_Internal_Login_Provider_Name() + { + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + await GenerateTokenAsync(user); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + var stored = await UserManager.GetAuthenticationTokenAsync( + user, + AbpTwoFactorTokenProvider.InternalLoginProvider, + GetTokenName(TwoFactorPurpose)); + + stored.ShouldNotBeNullOrEmpty(); + stored.ShouldContain("|"); + + // Must not leak under the Identity provider name itself. + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.GetAuthenticationTokenAsync(user, GetTokenProviderName(), TwoFactorPurpose)) + .ShouldBeNull(); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Stored_Value_Should_Be_Encrypted_Not_The_Raw_Code() + { + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var code = await GenerateTokenAsync(user); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + var stored = await UserManager.GetAuthenticationTokenAsync( + user, + AbpTwoFactorTokenProvider.InternalLoginProvider, + GetTokenName(TwoFactorPurpose)); + + stored.ShouldNotBeNull(); + stored.ShouldNotContain(code); + + // DataProtection output is substantially longer than a 6-digit code, which is a + // cheap sanity check that we're not accidentally storing the plaintext. + var protectedPart = stored.Substring(0, stored.LastIndexOf('|')); + protectedPart.Length.ShouldBeGreaterThan(code.Length * 4); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Consecutive_Generations_Should_Produce_Different_Protected_Payloads() + { + // DataProtection embeds per-call randomness, so even if the two underlying codes + // collide, their stored payloads will differ. This replaces the old + // "two generations produce different codes" assertion, which had a ~1e-6 flake rate. + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + await GenerateTokenAsync(user); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + var firstStored = await UserManager.GetAuthenticationTokenAsync( + user, AbpTwoFactorTokenProvider.InternalLoginProvider, GetTokenName(TwoFactorPurpose)); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + await GenerateTokenAsync(user); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + var secondStored = await UserManager.GetAuthenticationTokenAsync( + user, AbpTwoFactorTokenProvider.InternalLoginProvider, GetTokenName(TwoFactorPurpose)); + + firstStored.ShouldNotBeNull(); + secondStored.ShouldNotBeNull(); + secondStored.ShouldNotBe(firstStored); + + await uow.CompleteAsync(); + } + + [Fact] + public async Task Expired_Entry_Should_Not_Block_Future_Generation() + { + using var uow = UnitOfWorkManager.Begin(); + + var user = await UserRepository.GetAsync(TestData.UserJohnId); + var firstCode = await GenerateTokenAsync(user); + + // Force first entry into the past. + user = await UserRepository.GetAsync(TestData.UserJohnId); + var tokenName = GetTokenName(TwoFactorPurpose); + var stored = await UserManager.GetAuthenticationTokenAsync( + user, AbpTwoFactorTokenProvider.InternalLoginProvider, tokenName); + var protectedPart = stored!.Substring(0, stored.LastIndexOf('|')); + var expiredValue = protectedPart + "|" + + ToUnixSeconds(Clock.Now.AddMinutes(-1)).ToString(CultureInfo.InvariantCulture); + await UserManager.SetAuthenticationTokenAsync( + user, AbpTwoFactorTokenProvider.InternalLoginProvider, tokenName, expiredValue); + + // Verifying the expired code fails and cleans up. + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, firstCode)).ShouldBeFalse(); + + // New generation must succeed and verify. + user = await UserRepository.GetAsync(TestData.UserJohnId); + var secondCode = await GenerateTokenAsync(user); + + user = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(user, secondCode)).ShouldBeTrue(); + + await uow.CompleteAsync(); + } +}