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