mirror of https://github.com/abpframework/abp.git
Browse Source
Implement single active token providers for email change, email confirmation, and password resetpull/24933/head
committed by
GitHub
14 changed files with 710 additions and 0 deletions
@ -0,0 +1,27 @@ |
|||
using Microsoft.AspNetCore.DataProtection; |
|||
using Microsoft.AspNetCore.Identity; |
|||
using Microsoft.Extensions.Logging; |
|||
using Microsoft.Extensions.Options; |
|||
using Volo.Abp.Identity; |
|||
using Volo.Abp.Threading; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
/// <summary>
|
|||
/// Change email token provider that enforces only the most recently issued
|
|||
/// token to be valid, with a configurable expiration period.
|
|||
/// </summary>
|
|||
public class AbpChangeEmailTokenProvider : AbpSingleActiveTokenProvider |
|||
{ |
|||
public const string ProviderName = "AbpChangeEmail"; |
|||
|
|||
public AbpChangeEmailTokenProvider( |
|||
IDataProtectionProvider dataProtectionProvider, |
|||
IOptions<AbpChangeEmailTokenProviderOptions> options, |
|||
ILogger<DataProtectorTokenProvider<IdentityUser>> logger, |
|||
IIdentityUserRepository userRepository, |
|||
ICancellationTokenProvider cancellationTokenProvider) |
|||
: base(dataProtectionProvider, options, logger, userRepository, cancellationTokenProvider) |
|||
{ |
|||
} |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
using System; |
|||
using Microsoft.AspNetCore.Identity; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
public class AbpChangeEmailTokenProviderOptions : DataProtectionTokenProviderOptions |
|||
{ |
|||
public AbpChangeEmailTokenProviderOptions() |
|||
{ |
|||
Name = AbpChangeEmailTokenProvider.ProviderName; |
|||
TokenLifespan = TimeSpan.FromHours(2); |
|||
} |
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
using Microsoft.AspNetCore.DataProtection; |
|||
using Microsoft.AspNetCore.Identity; |
|||
using Microsoft.Extensions.Logging; |
|||
using Microsoft.Extensions.Options; |
|||
using Volo.Abp.Identity; |
|||
using Volo.Abp.Threading; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
/// <summary>
|
|||
/// Email confirmation token provider that enforces only the most recently issued
|
|||
/// token to be valid, with a configurable expiration period.
|
|||
/// Token reuse is bounded by the token expiry and the single-active policy:
|
|||
/// generating a new token overwrites the stored hash, invalidating all previous tokens.
|
|||
/// <para>
|
|||
/// Unlike password-reset and change-email flows, <see cref="Microsoft.AspNetCore.Identity.UserManager{TUser}.ConfirmEmailAsync"/>
|
|||
/// does <b>not</b> update the security stamp, so the token hash is NOT automatically
|
|||
/// invalidated after a successful confirmation. Callers that require single-use semantics
|
|||
/// must explicitly revoke the hash after confirmation:
|
|||
/// <code>
|
|||
/// var result = await userManager.ConfirmEmailAsync(user, token);
|
|||
/// if (result.Succeeded)
|
|||
/// await userManager.RemoveEmailConfirmationTokenAsync(user);
|
|||
/// </code>
|
|||
/// </para>
|
|||
/// </summary>
|
|||
public class AbpEmailConfirmationTokenProvider : AbpSingleActiveTokenProvider |
|||
{ |
|||
public const string ProviderName = "AbpEmailConfirmation"; |
|||
|
|||
public AbpEmailConfirmationTokenProvider( |
|||
IDataProtectionProvider dataProtectionProvider, |
|||
IOptions<AbpEmailConfirmationTokenProviderOptions> options, |
|||
ILogger<DataProtectorTokenProvider<IdentityUser>> logger, |
|||
IIdentityUserRepository userRepository, |
|||
ICancellationTokenProvider cancellationTokenProvider) |
|||
: base(dataProtectionProvider, options, logger, userRepository, cancellationTokenProvider) |
|||
{ |
|||
} |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
using System; |
|||
using Microsoft.AspNetCore.Identity; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
public class AbpEmailConfirmationTokenProviderOptions : DataProtectionTokenProviderOptions |
|||
{ |
|||
public AbpEmailConfirmationTokenProviderOptions() |
|||
{ |
|||
Name = AbpEmailConfirmationTokenProvider.ProviderName; |
|||
TokenLifespan = TimeSpan.FromHours(2); |
|||
} |
|||
} |
|||
@ -0,0 +1,27 @@ |
|||
using Microsoft.AspNetCore.DataProtection; |
|||
using Microsoft.AspNetCore.Identity; |
|||
using Microsoft.Extensions.Logging; |
|||
using Microsoft.Extensions.Options; |
|||
using Volo.Abp.Identity; |
|||
using Volo.Abp.Threading; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
/// <summary>
|
|||
/// Password reset token provider that enforces only the most recently issued
|
|||
/// token to be valid, with a configurable expiration period.
|
|||
/// </summary>
|
|||
public class AbpPasswordResetTokenProvider : AbpSingleActiveTokenProvider |
|||
{ |
|||
public const string ProviderName = "AbpPasswordReset"; |
|||
|
|||
public AbpPasswordResetTokenProvider( |
|||
IDataProtectionProvider dataProtectionProvider, |
|||
IOptions<AbpPasswordResetTokenProviderOptions> options, |
|||
ILogger<DataProtectorTokenProvider<IdentityUser>> logger, |
|||
IIdentityUserRepository userRepository, |
|||
ICancellationTokenProvider cancellationTokenProvider) |
|||
: base(dataProtectionProvider, options, logger, userRepository, cancellationTokenProvider) |
|||
{ |
|||
} |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
using System; |
|||
using Microsoft.AspNetCore.Identity; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
public class AbpPasswordResetTokenProviderOptions : DataProtectionTokenProviderOptions |
|||
{ |
|||
public AbpPasswordResetTokenProviderOptions() |
|||
{ |
|||
Name = AbpPasswordResetTokenProvider.ProviderName; |
|||
TokenLifespan = TimeSpan.FromHours(2); |
|||
} |
|||
} |
|||
@ -0,0 +1,94 @@ |
|||
using System; |
|||
using System.Security.Cryptography; |
|||
using System.Text; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.DataProtection; |
|||
using Microsoft.AspNetCore.Identity; |
|||
using Microsoft.Extensions.Logging; |
|||
using Microsoft.Extensions.Options; |
|||
using Volo.Abp.Domain.Repositories; |
|||
using Volo.Abp.Identity; |
|||
using Volo.Abp.Threading; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
/// <summary>
|
|||
/// Base class for ABP token providers that enforce a "single active token" policy:
|
|||
/// generating a new token automatically invalidates all previously issued tokens.
|
|||
/// Token validity is enforced by SecurityStamp verification (via the base class) and
|
|||
/// by the stored hash, which is overwritten each time a new token is generated.
|
|||
/// </summary>
|
|||
public abstract class AbpSingleActiveTokenProvider : DataProtectorTokenProvider<IdentityUser> |
|||
{ |
|||
/// <summary>
|
|||
/// The internal login provider name used to store token hashes in the user token table.
|
|||
/// Using a bracketed name clearly distinguishes these internal entries from real external
|
|||
/// login providers (e.g. Google, GitHub) stored in the same table.
|
|||
/// </summary>
|
|||
public const string InternalLoginProvider = "[AbpSingleActiveToken]"; |
|||
|
|||
protected IIdentityUserRepository UserRepository { get; } |
|||
|
|||
protected ICancellationTokenProvider CancellationTokenProvider { get; } |
|||
|
|||
protected AbpSingleActiveTokenProvider( |
|||
IDataProtectionProvider dataProtectionProvider, |
|||
IOptions<DataProtectionTokenProviderOptions> options, |
|||
ILogger<DataProtectorTokenProvider<IdentityUser>> logger, |
|||
IIdentityUserRepository userRepository, |
|||
ICancellationTokenProvider cancellationTokenProvider) |
|||
: base(dataProtectionProvider, options, logger) |
|||
{ |
|||
UserRepository = userRepository; |
|||
CancellationTokenProvider = cancellationTokenProvider; |
|||
} |
|||
|
|||
public override async Task<string> GenerateAsync(string purpose, UserManager<IdentityUser> manager, IdentityUser user) |
|||
{ |
|||
var token = await base.GenerateAsync(purpose, manager, user); |
|||
|
|||
await UserRepository.EnsureCollectionLoadedAsync(user, u => u.Tokens, CancellationTokenProvider.Token); |
|||
var tokenHash = ComputeSha256Hash(token); |
|||
user.SetToken(InternalLoginProvider, Options.Name + ":" + purpose, tokenHash); |
|||
|
|||
await manager.UpdateAsync(user); |
|||
|
|||
return token; |
|||
} |
|||
|
|||
public override async Task<bool> ValidateAsync(string purpose, string token, UserManager<IdentityUser> manager, IdentityUser user) |
|||
{ |
|||
if (!await base.ValidateAsync(purpose, token, manager, user)) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
await UserRepository.EnsureCollectionLoadedAsync(user, u => u.Tokens, CancellationTokenProvider.Token); |
|||
|
|||
var storedHash = user.FindToken(InternalLoginProvider, Options.Name + ":" + purpose)?.Value; |
|||
if (storedHash == null) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
var inputHash = ComputeSha256Hash(token); |
|||
try |
|||
{ |
|||
var storedHashBytes = Convert.FromHexString(storedHash); |
|||
var inputHashBytes = Convert.FromHexString(inputHash); |
|||
return CryptographicOperations.FixedTimeEquals(storedHashBytes, inputHashBytes); |
|||
} |
|||
catch (FormatException) |
|||
{ |
|||
// In case the stored hash is corrupted or not a valid hex string,
|
|||
// treat the token as invalid rather than throwing.
|
|||
return false; |
|||
} |
|||
} |
|||
|
|||
protected virtual string ComputeSha256Hash(string input) |
|||
{ |
|||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input)); |
|||
return Convert.ToHexString(bytes); |
|||
} |
|||
} |
|||
@ -0,0 +1,43 @@ |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Identity; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
/// <summary>
|
|||
/// Provides extension methods on <see cref="IdentityUserManager"/> for invalidating
|
|||
/// single-active tokens managed by <see cref="AbpSingleActiveTokenProvider"/>.
|
|||
/// These helpers live in the AspNetCore layer because they depend on
|
|||
/// <see cref="AbpSingleActiveTokenProvider.InternalLoginProvider"/>.
|
|||
/// </summary>
|
|||
public static class IdentityUserManagerSingleActiveTokenExtensions |
|||
{ |
|||
/// <summary>
|
|||
/// Removes the stored password-reset token hash for <paramref name="user"/>,
|
|||
/// immediately invalidating any previously issued password-reset token.
|
|||
/// </summary>
|
|||
public static Task<IdentityResult> RemovePasswordResetTokenAsync(this IdentityUserManager manager, IdentityUser user) |
|||
{ |
|||
var name = manager.Options.Tokens.PasswordResetTokenProvider + ":" + UserManager<IdentityUser>.ResetPasswordTokenPurpose; |
|||
return manager.RemoveAuthenticationTokenAsync(user, AbpSingleActiveTokenProvider.InternalLoginProvider, name); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Removes the stored email-confirmation token hash for <paramref name="user"/>,
|
|||
/// immediately invalidating any previously issued email-confirmation token.
|
|||
/// </summary>
|
|||
public static Task<IdentityResult> RemoveEmailConfirmationTokenAsync(this IdentityUserManager manager, IdentityUser user) |
|||
{ |
|||
var name = manager.Options.Tokens.EmailConfirmationTokenProvider + ":" + UserManager<IdentityUser>.ConfirmEmailTokenPurpose; |
|||
return manager.RemoveAuthenticationTokenAsync(user, AbpSingleActiveTokenProvider.InternalLoginProvider, name); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Removes the stored change-email token hash for <paramref name="user"/>,
|
|||
/// immediately invalidating any previously issued change-email token for <paramref name="newEmail"/>.
|
|||
/// </summary>
|
|||
public static Task<IdentityResult> RemoveChangeEmailTokenAsync(this IdentityUserManager manager, IdentityUser user, string newEmail) |
|||
{ |
|||
var name = manager.Options.Tokens.ChangeEmailTokenProvider + ":" + UserManager<IdentityUser>.GetChangeEmailTokenPurpose(newEmail); |
|||
return manager.RemoveAuthenticationTokenAsync(user, AbpSingleActiveTokenProvider.InternalLoginProvider, name); |
|||
} |
|||
} |
|||
@ -0,0 +1,68 @@ |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Identity; |
|||
using Microsoft.Extensions.Options; |
|||
using Shouldly; |
|||
using Volo.Abp.Uow; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
public class AbpChangeEmailTokenProvider_Tests : AbpSingleActiveTokenProviderTestBase |
|||
{ |
|||
private const string NewEmail = "newemail@example.com"; |
|||
|
|||
protected override Task<string> GenerateTokenAsync(IdentityUser user) |
|||
=> UserManager.GenerateChangeEmailTokenAsync(user, NewEmail); |
|||
|
|||
protected override Task<bool> VerifyTokenAsync(IdentityUser user, string token) |
|||
=> UserManager.VerifyUserTokenAsync( |
|||
user, |
|||
UserManager.Options.Tokens.ChangeEmailTokenProvider, |
|||
UserManager<IdentityUser>.GetChangeEmailTokenPurpose(NewEmail), |
|||
token); |
|||
|
|||
protected override string GetProviderName() |
|||
=> UserManager.Options.Tokens.ChangeEmailTokenProvider; |
|||
|
|||
protected override string GetPurpose() |
|||
=> UserManager<IdentityUser>.GetChangeEmailTokenPurpose(NewEmail); |
|||
|
|||
[Fact] |
|||
public void AbpChangeEmailTokenProvider_Should_Be_Registered() |
|||
{ |
|||
var identityOptions = GetRequiredService<IOptions<IdentityOptions>>().Value; |
|||
|
|||
identityOptions.Tokens.ProviderMap.ShouldContainKey(AbpChangeEmailTokenProvider.ProviderName); |
|||
identityOptions.Tokens.ProviderMap[AbpChangeEmailTokenProvider.ProviderName].ProviderType |
|||
.ShouldBe(typeof(AbpChangeEmailTokenProvider)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void ChangeEmailTokenProvider_Should_Be_Configured_As_Abp() |
|||
{ |
|||
var identityOptions = GetRequiredService<IOptions<IdentityOptions>>().Value; |
|||
|
|||
identityOptions.Tokens.ChangeEmailTokenProvider.ShouldBe(AbpChangeEmailTokenProvider.ProviderName); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Token_Should_Become_Invalid_After_Email_Change() |
|||
{ |
|||
using (var uow = UnitOfWorkManager.Begin()) |
|||
{ |
|||
var john = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
|
|||
var token = await UserManager.GenerateChangeEmailTokenAsync(john, NewEmail); |
|||
|
|||
john = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
var result = await UserManager.ChangeEmailAsync(john, NewEmail, token); |
|||
result.Succeeded.ShouldBeTrue(); |
|||
|
|||
// SecurityStamp has changed after the email change, so the old token must be invalid.
|
|||
john = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
(await VerifyTokenAsync(john, token)).ShouldBeFalse(); |
|||
|
|||
await uow.CompleteAsync(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,69 @@ |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Identity; |
|||
using Microsoft.Extensions.Options; |
|||
using Shouldly; |
|||
using Volo.Abp.Uow; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
public class AbpEmailConfirmationTokenProvider_Tests : AbpSingleActiveTokenProviderTestBase |
|||
{ |
|||
protected override Task<string> GenerateTokenAsync(IdentityUser user) |
|||
=> UserManager.GenerateEmailConfirmationTokenAsync(user); |
|||
|
|||
protected override Task<bool> VerifyTokenAsync(IdentityUser user, string token) |
|||
=> UserManager.VerifyUserTokenAsync( |
|||
user, |
|||
UserManager.Options.Tokens.EmailConfirmationTokenProvider, |
|||
UserManager<IdentityUser>.ConfirmEmailTokenPurpose, |
|||
token); |
|||
|
|||
protected override string GetProviderName() |
|||
=> UserManager.Options.Tokens.EmailConfirmationTokenProvider; |
|||
|
|||
protected override string GetPurpose() |
|||
=> UserManager<IdentityUser>.ConfirmEmailTokenPurpose; |
|||
|
|||
[Fact] |
|||
public void AbpEmailConfirmationTokenProvider_Should_Be_Registered() |
|||
{ |
|||
var identityOptions = GetRequiredService<IOptions<IdentityOptions>>().Value; |
|||
|
|||
identityOptions.Tokens.ProviderMap.ShouldContainKey(AbpEmailConfirmationTokenProvider.ProviderName); |
|||
identityOptions.Tokens.ProviderMap[AbpEmailConfirmationTokenProvider.ProviderName].ProviderType |
|||
.ShouldBe(typeof(AbpEmailConfirmationTokenProvider)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void EmailConfirmationTokenProvider_Should_Be_Configured_As_Abp() |
|||
{ |
|||
var identityOptions = GetRequiredService<IOptions<IdentityOptions>>().Value; |
|||
|
|||
identityOptions.Tokens.EmailConfirmationTokenProvider.ShouldBe(AbpEmailConfirmationTokenProvider.ProviderName); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Token_Should_Become_Invalid_After_Email_Confirmation_With_Explicit_Revocation() |
|||
{ |
|||
using (var uow = UnitOfWorkManager.Begin()) |
|||
{ |
|||
var john = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
|
|||
var token = await UserManager.GenerateEmailConfirmationTokenAsync(john); |
|||
|
|||
var result = await UserManager.ConfirmEmailAsync(john, token); |
|||
result.Succeeded.ShouldBeTrue(); |
|||
|
|||
// ConfirmEmailAsync does NOT update SecurityStamp, so the hash is not
|
|||
// automatically invalidated. Callers must explicitly revoke the hash.
|
|||
john = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
(await UserManager.RemoveEmailConfirmationTokenAsync(john)).Succeeded.ShouldBeTrue(); |
|||
|
|||
john = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
(await VerifyTokenAsync(john, token)).ShouldBeFalse(); |
|||
|
|||
await uow.CompleteAsync(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,66 @@ |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Identity; |
|||
using Microsoft.Extensions.Options; |
|||
using Shouldly; |
|||
using Volo.Abp.Uow; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
public class AbpPasswordResetTokenProvider_Tests : AbpSingleActiveTokenProviderTestBase |
|||
{ |
|||
protected override Task<string> GenerateTokenAsync(IdentityUser user) |
|||
=> UserManager.GeneratePasswordResetTokenAsync(user); |
|||
|
|||
protected override Task<bool> VerifyTokenAsync(IdentityUser user, string token) |
|||
=> UserManager.VerifyUserTokenAsync( |
|||
user, |
|||
UserManager.Options.Tokens.PasswordResetTokenProvider, |
|||
UserManager<IdentityUser>.ResetPasswordTokenPurpose, |
|||
token); |
|||
|
|||
protected override string GetProviderName() |
|||
=> UserManager.Options.Tokens.PasswordResetTokenProvider; |
|||
|
|||
protected override string GetPurpose() |
|||
=> UserManager<IdentityUser>.ResetPasswordTokenPurpose; |
|||
|
|||
[Fact] |
|||
public void AbpPasswordResetTokenProvider_Should_Be_Registered() |
|||
{ |
|||
var identityOptions = GetRequiredService<IOptions<IdentityOptions>>().Value; |
|||
|
|||
identityOptions.Tokens.ProviderMap.ShouldContainKey(AbpPasswordResetTokenProvider.ProviderName); |
|||
identityOptions.Tokens.ProviderMap[AbpPasswordResetTokenProvider.ProviderName].ProviderType |
|||
.ShouldBe(typeof(AbpPasswordResetTokenProvider)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void PasswordResetTokenProvider_Should_Be_Configured_As_Abp() |
|||
{ |
|||
var identityOptions = GetRequiredService<IOptions<IdentityOptions>>().Value; |
|||
|
|||
identityOptions.Tokens.PasswordResetTokenProvider.ShouldBe(AbpPasswordResetTokenProvider.ProviderName); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Token_Should_Become_Invalid_After_Password_Reset() |
|||
{ |
|||
using (var uow = UnitOfWorkManager.Begin()) |
|||
{ |
|||
var john = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
|
|||
var token = await UserManager.GeneratePasswordResetTokenAsync(john); |
|||
|
|||
john = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
var result = await UserManager.ResetPasswordAsync(john, token, "1q2w3E*NewP@ss!"); |
|||
result.Succeeded.ShouldBeTrue(); |
|||
|
|||
// SecurityStamp has changed after reset, so the old token must be invalid.
|
|||
john = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
(await VerifyTokenAsync(john, token)).ShouldBeFalse(); |
|||
|
|||
await uow.CompleteAsync(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,138 @@ |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Identity; |
|||
using Shouldly; |
|||
using Volo.Abp.Uow; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
/// <summary>
|
|||
/// Abstract base class that exercises the common behaviour of every
|
|||
/// <see cref="AbpSingleActiveTokenProvider"/> subclass.
|
|||
/// Concrete subclasses inject their provider-specific generate/verify helpers
|
|||
/// so the same test suite runs against each provider.
|
|||
/// </summary>
|
|||
public abstract class AbpSingleActiveTokenProviderTestBase : AbpIdentityAspNetCoreTestBase |
|||
{ |
|||
protected IIdentityUserRepository UserRepository { get; } |
|||
protected IdentityUserManager UserManager { get; } |
|||
protected IdentityTestData TestData { get; } |
|||
protected IUnitOfWorkManager UnitOfWorkManager { get; } |
|||
|
|||
protected AbpSingleActiveTokenProviderTestBase() |
|||
{ |
|||
UserRepository = GetRequiredService<IIdentityUserRepository>(); |
|||
UserManager = GetRequiredService<IdentityUserManager>(); |
|||
TestData = GetRequiredService<IdentityTestData>(); |
|||
UnitOfWorkManager = GetRequiredService<IUnitOfWorkManager>(); |
|||
} |
|||
|
|||
/// <summary>Generates a token for <paramref name="user"/> via the provider under test.</summary>
|
|||
protected abstract Task<string> GenerateTokenAsync(IdentityUser user); |
|||
|
|||
/// <summary>Verifies <paramref name="token"/> for <paramref name="user"/> via the provider under test.</summary>
|
|||
protected abstract Task<bool> VerifyTokenAsync(IdentityUser user, string token); |
|||
|
|||
/// <summary>Returns the provider name used to look up the stored hash.</summary>
|
|||
protected abstract string GetProviderName(); |
|||
|
|||
/// <summary>Returns the token purpose used as the hash key prefix.</summary>
|
|||
protected abstract string GetPurpose(); |
|||
|
|||
private string GetTokenHashName() => GetProviderName() + ":" + GetPurpose(); |
|||
|
|||
[Fact] |
|||
public async Task Generate_And_Verify_Token_Should_Succeed() |
|||
{ |
|||
using (var uow = UnitOfWorkManager.Begin()) |
|||
{ |
|||
var user = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
|
|||
var token = await GenerateTokenAsync(user); |
|||
token.ShouldNotBeNullOrEmpty(); |
|||
|
|||
user = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
(await VerifyTokenAsync(user, token)).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); |
|||
await GenerateTokenAsync(user); |
|||
|
|||
user = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
(await VerifyTokenAsync(user, "invalid-token-value")).ShouldBeFalse(); |
|||
|
|||
await uow.CompleteAsync(); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Second_Token_Should_Invalidate_First_Token() |
|||
{ |
|||
using (var uow = UnitOfWorkManager.Begin()) |
|||
{ |
|||
var user = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
var firstToken = await GenerateTokenAsync(user); |
|||
|
|||
user = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
var secondToken = await GenerateTokenAsync(user); |
|||
|
|||
user = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
|
|||
(await VerifyTokenAsync(user, firstToken)).ShouldBeFalse(); |
|||
(await VerifyTokenAsync(user, secondToken)).ShouldBeTrue(); |
|||
|
|||
await uow.CompleteAsync(); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Corrupted_Hash_Should_Return_False_Instead_Of_Throwing() |
|||
{ |
|||
using (var uow = UnitOfWorkManager.Begin()) |
|||
{ |
|||
var user = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
var token = await GenerateTokenAsync(user); |
|||
|
|||
// Overwrite with a non-hex string to simulate data corruption.
|
|||
user = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
await UserManager.SetAuthenticationTokenAsync(user, AbpSingleActiveTokenProvider.InternalLoginProvider, GetTokenHashName(), "not-valid-hex!!!"); |
|||
|
|||
user = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
|
|||
// ValidateAsync must catch FormatException internally and return false.
|
|||
(await VerifyTokenAsync(user, token)).ShouldBeFalse(); |
|||
|
|||
await uow.CompleteAsync(); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Token_Hash_Should_Persist_Across_UnitOfWork_Boundaries() |
|||
{ |
|||
string token; |
|||
|
|||
// UoW 1: generate; UpdateAsync inside GenerateAsync must write the hash to the DB.
|
|||
using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) |
|||
{ |
|||
var user = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
token = await GenerateTokenAsync(user); |
|||
await uow.CompleteAsync(); |
|||
} |
|||
|
|||
// UoW 2: validate with a fresh DbContext to confirm the hash was persisted.
|
|||
using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) |
|||
{ |
|||
var user = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
(await VerifyTokenAsync(user, token)).ShouldBeTrue(); |
|||
await uow.CompleteAsync(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,89 @@ |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Identity; |
|||
using Shouldly; |
|||
using Volo.Abp.Identity; |
|||
using Volo.Abp.Uow; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
public class IdentityUserManagerSingleActiveTokenExtensions_Tests : AbpIdentityAspNetCoreTestBase |
|||
{ |
|||
private const string NewEmail = "newemail@example.com"; |
|||
|
|||
protected IIdentityUserRepository UserRepository { get; } |
|||
protected IdentityUserManager UserManager { get; } |
|||
protected IdentityTestData TestData { get; } |
|||
protected IUnitOfWorkManager UnitOfWorkManager { get; } |
|||
|
|||
public IdentityUserManagerSingleActiveTokenExtensions_Tests() |
|||
{ |
|||
UserRepository = GetRequiredService<IIdentityUserRepository>(); |
|||
UserManager = GetRequiredService<IdentityUserManager>(); |
|||
TestData = GetRequiredService<IdentityTestData>(); |
|||
UnitOfWorkManager = GetRequiredService<IUnitOfWorkManager>(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Remove_PasswordReset_TokenHash() |
|||
{ |
|||
using (var uow = UnitOfWorkManager.Begin()) |
|||
{ |
|||
var user = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
var providerName = AbpSingleActiveTokenProvider.InternalLoginProvider; |
|||
var tokenKey = UserManager.Options.Tokens.PasswordResetTokenProvider + ":" + UserManager<IdentityUser>.ResetPasswordTokenPurpose; |
|||
|
|||
await UserManager.SetAuthenticationTokenAsync(user, providerName, tokenKey, "hash-value"); |
|||
(await UserManager.GetAuthenticationTokenAsync(user, providerName, tokenKey)).ShouldNotBeNull(); |
|||
|
|||
var result = await UserManager.RemovePasswordResetTokenAsync(user); |
|||
result.Succeeded.ShouldBeTrue(); |
|||
|
|||
(await UserManager.GetAuthenticationTokenAsync(user, providerName, tokenKey)).ShouldBeNull(); |
|||
|
|||
await uow.CompleteAsync(); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Remove_EmailConfirmation_TokenHash() |
|||
{ |
|||
using (var uow = UnitOfWorkManager.Begin()) |
|||
{ |
|||
var user = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
var providerName = AbpSingleActiveTokenProvider.InternalLoginProvider; |
|||
var tokenKey = UserManager.Options.Tokens.EmailConfirmationTokenProvider + ":" + UserManager<IdentityUser>.ConfirmEmailTokenPurpose; |
|||
|
|||
await UserManager.SetAuthenticationTokenAsync(user, providerName, tokenKey, "hash-value"); |
|||
(await UserManager.GetAuthenticationTokenAsync(user, providerName, tokenKey)).ShouldNotBeNull(); |
|||
|
|||
var result = await UserManager.RemoveEmailConfirmationTokenAsync(user); |
|||
result.Succeeded.ShouldBeTrue(); |
|||
|
|||
(await UserManager.GetAuthenticationTokenAsync(user, providerName, tokenKey)).ShouldBeNull(); |
|||
|
|||
await uow.CompleteAsync(); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Remove_ChangeEmail_TokenHash() |
|||
{ |
|||
using (var uow = UnitOfWorkManager.Begin()) |
|||
{ |
|||
var user = await UserRepository.GetAsync(TestData.UserJohnId); |
|||
var providerName = AbpSingleActiveTokenProvider.InternalLoginProvider; |
|||
var tokenKey = UserManager.Options.Tokens.ChangeEmailTokenProvider + ":" + UserManager<IdentityUser>.GetChangeEmailTokenPurpose(NewEmail); |
|||
|
|||
await UserManager.SetAuthenticationTokenAsync(user, providerName, tokenKey, "hash-value"); |
|||
(await UserManager.GetAuthenticationTokenAsync(user, providerName, tokenKey)).ShouldNotBeNull(); |
|||
|
|||
var result = await UserManager.RemoveChangeEmailTokenAsync(user, NewEmail); |
|||
result.Succeeded.ShouldBeTrue(); |
|||
|
|||
(await UserManager.GetAuthenticationTokenAsync(user, providerName, tokenKey)).ShouldBeNull(); |
|||
|
|||
await uow.CompleteAsync(); |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue