Browse Source

Merge pull request #25316 from abpframework/feat/abp-two-factor-token-providers

feat(identity): add single-use Email/Phone 2FA token providers
pull/25328/head
Engincan VESKE 1 week ago
committed by GitHub
parent
commit
198d64d4be
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 36
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailTwoFactorTokenProvider.cs
  2. 5
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailTwoFactorTokenProviderOptions.cs
  3. 4
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpIdentityAspNetCoreModule.cs
  4. 36
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPhoneNumberTwoFactorTokenProvider.cs
  5. 5
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPhoneNumberTwoFactorTokenProviderOptions.cs
  6. 2
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider.cs
  7. 224
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpTwoFactorTokenProvider.cs
  8. 12
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpTwoFactorTokenProviderOptions.cs
  9. 236
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpEmailTwoFactorTokenProvider_Tests.cs
  10. 223
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpPhoneNumberTwoFactorTokenProvider_Tests.cs
  11. 612
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpTwoFactorTokenProviderTestBase.cs

36
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;
/// <summary>
/// Single-use email 2FA code provider; replaces the ASP.NET Core Identity TOTP-based
/// <c>EmailTokenProvider&lt;TUser&gt;</c> under <see cref="TokenOptions.DefaultEmailProvider"/>.
/// </summary>
public class AbpEmailTwoFactorTokenProvider : AbpTwoFactorTokenProvider
{
public const string ProviderName = "AbpEmailTwoFactor";
public override string Name => ProviderName;
public AbpEmailTwoFactorTokenProvider(
IOptions<AbpEmailTwoFactorTokenProviderOptions> options,
IIdentityUserRepository userRepository,
ICancellationTokenProvider cancellationTokenProvider,
IClock clock,
IDataProtectionProvider dataProtectionProvider)
: base(options.Value, userRepository, cancellationTokenProvider, clock, dataProtectionProvider)
{
}
public override async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<IdentityUser> manager, IdentityUser user)
{
var email = await manager.GetEmailAsync(user);
return !string.IsNullOrWhiteSpace(email) && await manager.IsEmailConfirmedAsync(user);
}
}

5
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
{
}

4
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>(AbpPasswordResetTokenProvider.ProviderName)
.AddTokenProvider<AbpEmailConfirmationTokenProvider>(AbpEmailConfirmationTokenProvider.ProviderName)
.AddTokenProvider<AbpChangeEmailTokenProvider>(AbpChangeEmailTokenProvider.ProviderName)
.AddTokenProvider<AbpEmailTwoFactorTokenProvider>(TokenOptions.DefaultEmailProvider)
.AddTokenProvider<AbpPhoneNumberTwoFactorTokenProvider>(TokenOptions.DefaultPhoneProvider)
.AddSignInManager<AbpSignInManager>()
.AddUserValidator<AbpIdentityUserValidator>();
});

36
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;
/// <summary>
/// Single-use phone 2FA code provider; replaces the ASP.NET Core Identity TOTP-based
/// <c>PhoneNumberTokenProvider&lt;TUser&gt;</c> under <see cref="TokenOptions.DefaultPhoneProvider"/>.
/// </summary>
public class AbpPhoneNumberTwoFactorTokenProvider : AbpTwoFactorTokenProvider
{
public const string ProviderName = "AbpPhoneNumberTwoFactor";
public override string Name => ProviderName;
public AbpPhoneNumberTwoFactorTokenProvider(
IOptions<AbpPhoneNumberTwoFactorTokenProviderOptions> options,
IIdentityUserRepository userRepository,
ICancellationTokenProvider cancellationTokenProvider,
IClock clock,
IDataProtectionProvider dataProtectionProvider)
: base(options.Value, userRepository, cancellationTokenProvider, clock, dataProtectionProvider)
{
}
public override async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<IdentityUser> manager, IdentityUser user)
{
var phoneNumber = await manager.GetPhoneNumberAsync(user);
return !string.IsNullOrWhiteSpace(phoneNumber) && await manager.IsPhoneNumberConfirmedAsync(user);
}
}

5
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
{
}

2
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;
}

224
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;
/// <summary>
/// Base class for ABP two-factor verification code providers (e.g. Email, Phone).
/// Generates a numeric OTP, stores it encrypted under <see cref="IDataProtector"/>
/// 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.
/// </summary>
public abstract class AbpTwoFactorTokenProvider : IUserTwoFactorTokenProvider<IdentityUser>
{
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; }
/// <summary>Unique provider name; used as the stored-token key prefix and DataProtection purpose segment.</summary>
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<bool> CanGenerateTwoFactorTokenAsync(UserManager<IdentityUser> manager, IdentityUser user);
public virtual async Task<string> GenerateAsync(string purpose, UserManager<IdentityUser> 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<bool> ValidateAsync(string purpose, string token, UserManager<IdentityUser> 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;
}
/// <summary>
/// Removes the stored entry. Throws <see cref="AbpIdentityResultException"/> on persistence
/// failure so the successful-verification path knows the consume actually committed.
/// Cleanup paths use <see cref="TryRemoveStoredTokenAsync"/> instead.
/// </summary>
protected virtual async Task RemoveStoredTokenAsync(UserManager<IdentityUser> manager, IdentityUser user, string tokenName)
{
user.RemoveToken(InternalLoginProvider, tokenName);
(await manager.UpdateAsync(user)).CheckErrors();
}
/// <summary>
/// Cleanup variant that swallows concurrent-update failures. The next
/// <see cref="GenerateAsync"/> will overwrite whatever remains.
/// </summary>
protected virtual async Task TryRemoveStoredTokenAsync(UserManager<IdentityUser> 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;
}
}

12
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
{
/// <summary>Default: 3 minutes.</summary>
public TimeSpan TokenLifespan { get; set; } = TimeSpan.FromMinutes(3);
/// <summary>Default: 6. Valid range: 1 to 9 (inclusive).</summary>
public int CodeLength { get; set; } = 6;
}

236
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<IOptions<IdentityOptions>>().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<IOptions<AbpEmailTwoFactorTokenProviderOptions>>().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<AbpEmailTwoFactorTokenProvider>();
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<ICancellationTokenProvider>(),
Clock,
GetRequiredService<IDataProtectionProvider>());
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<ICancellationTokenProvider>(),
Clock,
GetRequiredService<IDataProtectionProvider>());
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<AbpEmailTwoFactorTokenProvider>();
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<AbpEmailTwoFactorTokenProvider>();
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<IDataProtectionProvider>();
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();
}
}

223
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<IOptions<IdentityOptions>>().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<IOptions<AbpPhoneNumberTwoFactorTokenProviderOptions>>().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<AbpPhoneNumberTwoFactorTokenProvider>();
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<ICancellationTokenProvider>(),
Clock,
GetRequiredService<IDataProtectionProvider>());
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<ICancellationTokenProvider>(),
Clock,
GetRequiredService<IDataProtectionProvider>());
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<AbpPhoneNumberTwoFactorTokenProvider>();
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<AbpPhoneNumberTwoFactorTokenProvider>();
provider.Name.ShouldBe(AbpPhoneNumberTwoFactorTokenProvider.ProviderName);
AbpPhoneNumberTwoFactorTokenProvider.ProviderName.ShouldBe("AbpPhoneNumberTwoFactor");
}
}

612
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;
/// <summary>
/// Abstract base class that exercises the common behaviour of every
/// <see cref="AbpTwoFactorTokenProvider"/> subclass. Concrete subclasses wire up
/// the Identity API calls that route to the provider under test.
/// </summary>
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<IIdentityUserRepository>();
UserManager = GetRequiredService<IdentityUserManager>();
TestData = GetRequiredService<IdentityTestData>();
UnitOfWorkManager = GetRequiredService<IUnitOfWorkManager>();
Clock = GetRequiredService<IClock>();
}
/// <summary>Identity provider name (e.g. "Email", "Phone") routed to the provider under test.</summary>
protected abstract string GetTokenProviderName();
/// <summary>Internal <see cref="AbpTwoFactorTokenProvider.Name"/> used as the stored-token key prefix.</summary>
protected abstract string GetInternalProviderName();
protected virtual Task<string> GenerateTokenAsync(IdentityUser user, string purpose = TwoFactorPurpose)
=> UserManager.GenerateUserTokenAsync(user, GetTokenProviderName(), purpose);
protected virtual Task<bool> VerifyTokenAsync(IdentityUser user, string token, string purpose = TwoFactorPurpose)
=> UserManager.VerifyUserTokenAsync(user, GetTokenProviderName(), purpose, token);
protected string GetTokenName(string purpose) => GetInternalProviderName() + ":" + purpose;
/// <summary>Deterministically produce a code that is guaranteed not to equal <paramref name="code"/>.</summary>
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();
}
}
Loading…
Cancel
Save