mirror of https://github.com/abpframework/abp.git
committed by
GitHub
11 changed files with 1393 additions and 2 deletions
@ -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<TUser></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); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,5 @@ |
|||||
|
namespace Volo.Abp.Identity.AspNetCore; |
||||
|
|
||||
|
public class AbpEmailTwoFactorTokenProviderOptions : AbpTwoFactorTokenProviderOptions |
||||
|
{ |
||||
|
} |
||||
@ -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<TUser></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); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,5 @@ |
|||||
|
namespace Volo.Abp.Identity.AspNetCore; |
||||
|
|
||||
|
public class AbpPhoneNumberTwoFactorTokenProviderOptions : AbpTwoFactorTokenProviderOptions |
||||
|
{ |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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"); |
||||
|
} |
||||
|
} |
||||
@ -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…
Reference in new issue