Browse Source

Implement single active token providers for email change, email confirmation, and password reset

pull/24926/head
maliming 1 month ago
parent
commit
71b52a8ee2
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 25
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider.cs
  2. 13
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProviderOptions.cs
  3. 38
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider.cs
  4. 13
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProviderOptions.cs
  5. 10
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpIdentityAspNetCoreModule.cs
  6. 25
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider.cs
  7. 13
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProviderOptions.cs
  8. 73
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider.cs
  9. 43
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions.cs
  10. 175
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider_Tests.cs
  11. 178
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider_Tests.cs
  12. 173
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider_Tests.cs
  13. 89
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions_Tests.cs

25
modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider.cs

@ -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)
{
}
}

13
modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProviderOptions.cs

@ -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);
}
}

38
modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider.cs

@ -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)
{
}
}

13
modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProviderOptions.cs

@ -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);
}
}

10
modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpIdentityAspNetCoreModule.cs

@ -20,6 +20,9 @@ public class AbpIdentityAspNetCoreModule : AbpModule
builder
.AddDefaultTokenProviders()
.AddTokenProvider<LinkUserTokenProvider>(LinkUserTokenProviderConsts.LinkUserTokenProviderName)
.AddTokenProvider<AbpPasswordResetTokenProvider>(AbpPasswordResetTokenProvider.ProviderName)
.AddTokenProvider<AbpEmailConfirmationTokenProvider>(AbpEmailConfirmationTokenProvider.ProviderName)
.AddTokenProvider<AbpChangeEmailTokenProvider>(AbpChangeEmailTokenProvider.ProviderName)
.AddSignInManager<AbpSignInManager>()
.AddUserValidator<AbpIdentityUserValidator>();
});
@ -27,6 +30,13 @@ public class AbpIdentityAspNetCoreModule : AbpModule
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<IdentityOptions>(options =>
{
options.Tokens.PasswordResetTokenProvider = AbpPasswordResetTokenProvider.ProviderName;
options.Tokens.EmailConfirmationTokenProvider = AbpEmailConfirmationTokenProvider.ProviderName;
options.Tokens.ChangeEmailTokenProvider = AbpChangeEmailTokenProvider.ProviderName;
});
//(TODO: Extract an extension method like IdentityBuilder.AddAbpSecurityStampValidator())
context.Services.AddScoped<AbpSecurityStampValidator>();
context.Services.AddScoped(typeof(SecurityStampValidator<IdentityUser>), provider => provider.GetService(typeof(AbpSecurityStampValidator)));

25
modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider.cs

@ -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)
{
}
}

13
modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProviderOptions.cs

@ -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);
}
}

73
modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpSingleActiveTokenProvider.cs

@ -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);
}
}

43
modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions.cs

@ -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);
}
}

175
modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpChangeEmailTokenProvider_Tests.cs

@ -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();
}
}
}

178
modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpEmailConfirmationTokenProvider_Tests.cs

@ -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();
}
}
}

173
modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpPasswordResetTokenProvider_Tests.cs

@ -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();
}
}
}

89
modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions_Tests.cs

@ -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…
Cancel
Save