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