mirror of https://github.com/abpframework/abp.git
13 changed files with 868 additions and 0 deletions
@ -0,0 +1,25 @@ |
|||||
|
using Microsoft.AspNetCore.DataProtection; |
||||
|
using Microsoft.AspNetCore.Identity; |
||||
|
using Microsoft.Extensions.Logging; |
||||
|
using Microsoft.Extensions.Options; |
||||
|
using Volo.Abp.Identity; |
||||
|
|
||||
|
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) |
||||
|
: base(dataProtectionProvider, options, logger, userRepository) |
||||
|
{ |
||||
|
} |
||||
|
} |
||||
@ -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,38 @@ |
|||||
|
using Microsoft.AspNetCore.DataProtection; |
||||
|
using Microsoft.AspNetCore.Identity; |
||||
|
using Microsoft.Extensions.Logging; |
||||
|
using Microsoft.Extensions.Options; |
||||
|
using Volo.Abp.Identity; |
||||
|
|
||||
|
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) |
||||
|
: base(dataProtectionProvider, options, logger, userRepository) |
||||
|
{ |
||||
|
} |
||||
|
} |
||||
@ -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,25 @@ |
|||||
|
using Microsoft.AspNetCore.DataProtection; |
||||
|
using Microsoft.AspNetCore.Identity; |
||||
|
using Microsoft.Extensions.Logging; |
||||
|
using Microsoft.Extensions.Options; |
||||
|
using Volo.Abp.Identity; |
||||
|
|
||||
|
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) |
||||
|
: base(dataProtectionProvider, options, logger, userRepository) |
||||
|
{ |
||||
|
} |
||||
|
} |
||||
@ -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,73 @@ |
|||||
|
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; |
||||
|
|
||||
|
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> |
||||
|
{ |
||||
|
public const string TokenHashSuffix = "_TokenHash"; |
||||
|
|
||||
|
protected IIdentityUserRepository UserRepository { get; } |
||||
|
|
||||
|
protected AbpSingleActiveTokenProvider( |
||||
|
IDataProtectionProvider dataProtectionProvider, |
||||
|
IOptions<DataProtectionTokenProviderOptions> options, |
||||
|
ILogger<DataProtectorTokenProvider<IdentityUser>> logger, |
||||
|
IIdentityUserRepository userRepository) |
||||
|
: base(dataProtectionProvider, options, logger) |
||||
|
{ |
||||
|
UserRepository = userRepository; |
||||
|
} |
||||
|
|
||||
|
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); |
||||
|
var tokenHash = ComputeSha256Hash(token); |
||||
|
user.SetToken(Options.Name, purpose + TokenHashSuffix, 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); |
||||
|
|
||||
|
var storedHash = user.FindToken(Options.Name, purpose + TokenHashSuffix)?.Value; |
||||
|
if (storedHash == null) |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
var inputHash = ComputeSha256Hash(token); |
||||
|
return string.Equals(storedHash, inputHash, StringComparison.Ordinal); |
||||
|
} |
||||
|
|
||||
|
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.TokenHashSuffix"/>.
|
||||
|
/// </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 = UserManager<IdentityUser>.ResetPasswordTokenPurpose + AbpSingleActiveTokenProvider.TokenHashSuffix; |
||||
|
return manager.RemoveAuthenticationTokenAsync(user, manager.Options.Tokens.PasswordResetTokenProvider, 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 = UserManager<IdentityUser>.ConfirmEmailTokenPurpose + AbpSingleActiveTokenProvider.TokenHashSuffix; |
||||
|
return manager.RemoveAuthenticationTokenAsync(user, manager.Options.Tokens.EmailConfirmationTokenProvider, 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 = UserManager<IdentityUser>.GetChangeEmailTokenPurpose(newEmail) + AbpSingleActiveTokenProvider.TokenHashSuffix; |
||||
|
return manager.RemoveAuthenticationTokenAsync(user, manager.Options.Tokens.ChangeEmailTokenProvider, name); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,175 @@ |
|||||
|
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 : 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 AbpChangeEmailTokenProvider_Tests() |
||||
|
{ |
||||
|
UserRepository = GetRequiredService<IIdentityUserRepository>(); |
||||
|
UserManager = GetRequiredService<IdentityUserManager>(); |
||||
|
TestData = GetRequiredService<IdentityTestData>(); |
||||
|
UnitOfWorkManager = GetRequiredService<IUnitOfWorkManager>(); |
||||
|
} |
||||
|
|
||||
|
[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 Generate_And_Verify_Change_Email_Token_Should_Succeed() |
||||
|
{ |
||||
|
using (var uow = UnitOfWorkManager.Begin()) |
||||
|
{ |
||||
|
var john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
|
||||
|
var token = await UserManager.GenerateChangeEmailTokenAsync(john, NewEmail); |
||||
|
token.ShouldNotBeNullOrEmpty(); |
||||
|
|
||||
|
var isValid = await UserManager.VerifyUserTokenAsync( |
||||
|
john, |
||||
|
UserManager.Options.Tokens.ChangeEmailTokenProvider, |
||||
|
UserManager<IdentityUser>.GetChangeEmailTokenPurpose(NewEmail), |
||||
|
token); |
||||
|
|
||||
|
isValid.ShouldBeTrue(); |
||||
|
|
||||
|
await uow.CompleteAsync(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Invalid_Token_Should_Fail_Verification() |
||||
|
{ |
||||
|
using (var uow = UnitOfWorkManager.Begin()) |
||||
|
{ |
||||
|
var john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
|
||||
|
await UserManager.GenerateChangeEmailTokenAsync(john, NewEmail); |
||||
|
|
||||
|
var isValid = await UserManager.VerifyUserTokenAsync( |
||||
|
john, |
||||
|
UserManager.Options.Tokens.ChangeEmailTokenProvider, |
||||
|
UserManager<IdentityUser>.GetChangeEmailTokenPurpose(NewEmail), |
||||
|
"invalid-token-value"); |
||||
|
|
||||
|
isValid.ShouldBeFalse(); |
||||
|
|
||||
|
await uow.CompleteAsync(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Second_Token_Should_Invalidate_First_Token() |
||||
|
{ |
||||
|
using (var uow = UnitOfWorkManager.Begin()) |
||||
|
{ |
||||
|
var john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
|
||||
|
var firstToken = await UserManager.GenerateChangeEmailTokenAsync(john, NewEmail); |
||||
|
|
||||
|
john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
var secondToken = await UserManager.GenerateChangeEmailTokenAsync(john, NewEmail); |
||||
|
|
||||
|
john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
|
||||
|
var firstTokenValid = await UserManager.VerifyUserTokenAsync( |
||||
|
john, |
||||
|
UserManager.Options.Tokens.ChangeEmailTokenProvider, |
||||
|
UserManager<IdentityUser>.GetChangeEmailTokenPurpose(NewEmail), |
||||
|
firstToken); |
||||
|
|
||||
|
var secondTokenValid = await UserManager.VerifyUserTokenAsync( |
||||
|
john, |
||||
|
UserManager.Options.Tokens.ChangeEmailTokenProvider, |
||||
|
UserManager<IdentityUser>.GetChangeEmailTokenPurpose(NewEmail), |
||||
|
secondToken); |
||||
|
|
||||
|
firstTokenValid.ShouldBeFalse(); |
||||
|
secondTokenValid.ShouldBeTrue(); |
||||
|
|
||||
|
await uow.CompleteAsync(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[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); |
||||
|
var isValid = await UserManager.VerifyUserTokenAsync( |
||||
|
john, |
||||
|
UserManager.Options.Tokens.ChangeEmailTokenProvider, |
||||
|
UserManager<IdentityUser>.GetChangeEmailTokenPurpose(NewEmail), |
||||
|
token); |
||||
|
|
||||
|
isValid.ShouldBeFalse(); |
||||
|
|
||||
|
await uow.CompleteAsync(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Token_Hash_Should_Persist_Across_UnitOfWork_Boundaries() |
||||
|
{ |
||||
|
string token; |
||||
|
|
||||
|
// UoW 1: generate the token; UpdateAsync inside GenerateAsync must persist the hash.
|
||||
|
using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) |
||||
|
{ |
||||
|
var john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
token = await UserManager.GenerateChangeEmailTokenAsync(john, NewEmail); |
||||
|
await uow.CompleteAsync(); |
||||
|
} |
||||
|
|
||||
|
// UoW 2: validate using a fresh DbContext to confirm the hash was written to the DB.
|
||||
|
using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) |
||||
|
{ |
||||
|
var john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
var isValid = await UserManager.VerifyUserTokenAsync( |
||||
|
john, |
||||
|
UserManager.Options.Tokens.ChangeEmailTokenProvider, |
||||
|
UserManager<IdentityUser>.GetChangeEmailTokenPurpose(NewEmail), |
||||
|
token); |
||||
|
|
||||
|
isValid.ShouldBeTrue(); |
||||
|
await uow.CompleteAsync(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,178 @@ |
|||||
|
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 : AbpIdentityAspNetCoreTestBase |
||||
|
{ |
||||
|
protected IIdentityUserRepository UserRepository { get; } |
||||
|
protected IdentityUserManager UserManager { get; } |
||||
|
protected IdentityTestData TestData { get; } |
||||
|
protected IUnitOfWorkManager UnitOfWorkManager { get; } |
||||
|
|
||||
|
public AbpEmailConfirmationTokenProvider_Tests() |
||||
|
{ |
||||
|
UserRepository = GetRequiredService<IIdentityUserRepository>(); |
||||
|
UserManager = GetRequiredService<IdentityUserManager>(); |
||||
|
TestData = GetRequiredService<IdentityTestData>(); |
||||
|
UnitOfWorkManager = GetRequiredService<IUnitOfWorkManager>(); |
||||
|
} |
||||
|
|
||||
|
[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 Generate_And_Verify_Email_Confirmation_Token_Should_Succeed() |
||||
|
{ |
||||
|
using (var uow = UnitOfWorkManager.Begin()) |
||||
|
{ |
||||
|
var john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
|
||||
|
var token = await UserManager.GenerateEmailConfirmationTokenAsync(john); |
||||
|
token.ShouldNotBeNullOrEmpty(); |
||||
|
|
||||
|
var isValid = await UserManager.VerifyUserTokenAsync( |
||||
|
john, |
||||
|
UserManager.Options.Tokens.EmailConfirmationTokenProvider, |
||||
|
UserManager<IdentityUser>.ConfirmEmailTokenPurpose, |
||||
|
token); |
||||
|
|
||||
|
isValid.ShouldBeTrue(); |
||||
|
|
||||
|
await uow.CompleteAsync(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Invalid_Token_Should_Fail_Verification() |
||||
|
{ |
||||
|
using (var uow = UnitOfWorkManager.Begin()) |
||||
|
{ |
||||
|
var john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
|
||||
|
await UserManager.GenerateEmailConfirmationTokenAsync(john); |
||||
|
|
||||
|
var isValid = await UserManager.VerifyUserTokenAsync( |
||||
|
john, |
||||
|
UserManager.Options.Tokens.EmailConfirmationTokenProvider, |
||||
|
UserManager<IdentityUser>.ConfirmEmailTokenPurpose, |
||||
|
"invalid-token-value"); |
||||
|
|
||||
|
isValid.ShouldBeFalse(); |
||||
|
|
||||
|
await uow.CompleteAsync(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Second_Token_Should_Invalidate_First_Token() |
||||
|
{ |
||||
|
using (var uow = UnitOfWorkManager.Begin()) |
||||
|
{ |
||||
|
var john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
|
||||
|
var firstToken = await UserManager.GenerateEmailConfirmationTokenAsync(john); |
||||
|
|
||||
|
john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
var secondToken = await UserManager.GenerateEmailConfirmationTokenAsync(john); |
||||
|
|
||||
|
john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
|
||||
|
var firstTokenValid = await UserManager.VerifyUserTokenAsync( |
||||
|
john, |
||||
|
UserManager.Options.Tokens.EmailConfirmationTokenProvider, |
||||
|
UserManager<IdentityUser>.ConfirmEmailTokenPurpose, |
||||
|
firstToken); |
||||
|
|
||||
|
var secondTokenValid = await UserManager.VerifyUserTokenAsync( |
||||
|
john, |
||||
|
UserManager.Options.Tokens.EmailConfirmationTokenProvider, |
||||
|
UserManager<IdentityUser>.ConfirmEmailTokenPurpose, |
||||
|
secondToken); |
||||
|
|
||||
|
firstTokenValid.ShouldBeFalse(); |
||||
|
secondTokenValid.ShouldBeTrue(); |
||||
|
|
||||
|
await uow.CompleteAsync(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[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 that require single-use semantics must
|
||||
|
// explicitly revoke the stored token hash after a successful confirmation.
|
||||
|
john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
var removeResult = await UserManager.RemoveEmailConfirmationTokenAsync(john); |
||||
|
removeResult.Succeeded.ShouldBeTrue(); |
||||
|
|
||||
|
john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
var isValid = await UserManager.VerifyUserTokenAsync( |
||||
|
john, |
||||
|
UserManager.Options.Tokens.EmailConfirmationTokenProvider, |
||||
|
UserManager<IdentityUser>.ConfirmEmailTokenPurpose, |
||||
|
token); |
||||
|
|
||||
|
isValid.ShouldBeFalse(); |
||||
|
|
||||
|
await uow.CompleteAsync(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Token_Hash_Should_Persist_Across_UnitOfWork_Boundaries() |
||||
|
{ |
||||
|
string token; |
||||
|
|
||||
|
// UoW 1: generate the token; UpdateAsync inside GenerateAsync must persist the hash.
|
||||
|
using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) |
||||
|
{ |
||||
|
var john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
token = await UserManager.GenerateEmailConfirmationTokenAsync(john); |
||||
|
await uow.CompleteAsync(); |
||||
|
} |
||||
|
|
||||
|
// UoW 2: validate using a fresh DbContext to confirm the hash was written to the DB.
|
||||
|
using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) |
||||
|
{ |
||||
|
var john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
var isValid = await UserManager.VerifyUserTokenAsync( |
||||
|
john, |
||||
|
UserManager.Options.Tokens.EmailConfirmationTokenProvider, |
||||
|
UserManager<IdentityUser>.ConfirmEmailTokenPurpose, |
||||
|
token); |
||||
|
|
||||
|
isValid.ShouldBeTrue(); |
||||
|
await uow.CompleteAsync(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,173 @@ |
|||||
|
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 : AbpIdentityAspNetCoreTestBase |
||||
|
{ |
||||
|
protected IIdentityUserRepository UserRepository { get; } |
||||
|
protected IdentityUserManager UserManager { get; } |
||||
|
protected IdentityTestData TestData { get; } |
||||
|
protected IUnitOfWorkManager UnitOfWorkManager { get; } |
||||
|
|
||||
|
public AbpPasswordResetTokenProvider_Tests() |
||||
|
{ |
||||
|
UserRepository = GetRequiredService<IIdentityUserRepository>(); |
||||
|
UserManager = GetRequiredService<IdentityUserManager>(); |
||||
|
TestData = GetRequiredService<IdentityTestData>(); |
||||
|
UnitOfWorkManager = GetRequiredService<IUnitOfWorkManager>(); |
||||
|
} |
||||
|
|
||||
|
[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 Generate_And_Verify_Password_Reset_Token_Should_Succeed() |
||||
|
{ |
||||
|
using (var uow = UnitOfWorkManager.Begin()) |
||||
|
{ |
||||
|
var john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
|
||||
|
var token = await UserManager.GeneratePasswordResetTokenAsync(john); |
||||
|
token.ShouldNotBeNullOrEmpty(); |
||||
|
|
||||
|
var isValid = await UserManager.VerifyUserTokenAsync( |
||||
|
john, |
||||
|
UserManager.Options.Tokens.PasswordResetTokenProvider, |
||||
|
UserManager<IdentityUser>.ResetPasswordTokenPurpose, |
||||
|
token); |
||||
|
|
||||
|
isValid.ShouldBeTrue(); |
||||
|
|
||||
|
await uow.CompleteAsync(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Invalid_Token_Should_Fail_Verification() |
||||
|
{ |
||||
|
using (var uow = UnitOfWorkManager.Begin()) |
||||
|
{ |
||||
|
var john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
|
||||
|
await UserManager.GeneratePasswordResetTokenAsync(john); |
||||
|
|
||||
|
var isValid = await UserManager.VerifyUserTokenAsync( |
||||
|
john, |
||||
|
UserManager.Options.Tokens.PasswordResetTokenProvider, |
||||
|
UserManager<IdentityUser>.ResetPasswordTokenPurpose, |
||||
|
"invalid-token-value"); |
||||
|
|
||||
|
isValid.ShouldBeFalse(); |
||||
|
|
||||
|
await uow.CompleteAsync(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Second_Token_Should_Invalidate_First_Token() |
||||
|
{ |
||||
|
using (var uow = UnitOfWorkManager.Begin()) |
||||
|
{ |
||||
|
var john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
|
||||
|
var firstToken = await UserManager.GeneratePasswordResetTokenAsync(john); |
||||
|
|
||||
|
john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
var secondToken = await UserManager.GeneratePasswordResetTokenAsync(john); |
||||
|
|
||||
|
john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
|
||||
|
var firstTokenValid = await UserManager.VerifyUserTokenAsync( |
||||
|
john, |
||||
|
UserManager.Options.Tokens.PasswordResetTokenProvider, |
||||
|
UserManager<IdentityUser>.ResetPasswordTokenPurpose, |
||||
|
firstToken); |
||||
|
|
||||
|
var secondTokenValid = await UserManager.VerifyUserTokenAsync( |
||||
|
john, |
||||
|
UserManager.Options.Tokens.PasswordResetTokenProvider, |
||||
|
UserManager<IdentityUser>.ResetPasswordTokenPurpose, |
||||
|
secondToken); |
||||
|
|
||||
|
firstTokenValid.ShouldBeFalse(); |
||||
|
secondTokenValid.ShouldBeTrue(); |
||||
|
|
||||
|
await uow.CompleteAsync(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[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); |
||||
|
var isValid = await UserManager.VerifyUserTokenAsync( |
||||
|
john, |
||||
|
UserManager.Options.Tokens.PasswordResetTokenProvider, |
||||
|
UserManager<IdentityUser>.ResetPasswordTokenPurpose, |
||||
|
token); |
||||
|
|
||||
|
isValid.ShouldBeFalse(); |
||||
|
|
||||
|
await uow.CompleteAsync(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Token_Hash_Should_Persist_Across_UnitOfWork_Boundaries() |
||||
|
{ |
||||
|
string token; |
||||
|
|
||||
|
// UoW 1: generate the token; UpdateAsync inside GenerateAsync must persist the hash.
|
||||
|
using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) |
||||
|
{ |
||||
|
var john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
token = await UserManager.GeneratePasswordResetTokenAsync(john); |
||||
|
await uow.CompleteAsync(); |
||||
|
} |
||||
|
|
||||
|
// UoW 2: validate using a fresh DbContext to confirm the hash was written to the DB.
|
||||
|
using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) |
||||
|
{ |
||||
|
var john = await UserRepository.GetAsync(TestData.UserJohnId); |
||||
|
var isValid = await UserManager.VerifyUserTokenAsync( |
||||
|
john, |
||||
|
UserManager.Options.Tokens.PasswordResetTokenProvider, |
||||
|
UserManager<IdentityUser>.ResetPasswordTokenPurpose, |
||||
|
token); |
||||
|
|
||||
|
isValid.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 = UserManager.Options.Tokens.PasswordResetTokenProvider; |
||||
|
var tokenKey = UserManager<IdentityUser>.ResetPasswordTokenPurpose + AbpSingleActiveTokenProvider.TokenHashSuffix; |
||||
|
|
||||
|
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 = UserManager.Options.Tokens.EmailConfirmationTokenProvider; |
||||
|
var tokenKey = UserManager<IdentityUser>.ConfirmEmailTokenPurpose + AbpSingleActiveTokenProvider.TokenHashSuffix; |
||||
|
|
||||
|
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 = UserManager.Options.Tokens.ChangeEmailTokenProvider; |
||||
|
var tokenKey = UserManager<IdentityUser>.GetChangeEmailTokenPurpose(NewEmail) + AbpSingleActiveTokenProvider.TokenHashSuffix; |
||||
|
|
||||
|
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