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();
+ }
+}