mirror of https://github.com/abpframework/abp.git
Browse Source
- Register them in AbpIdentityDomainModule so both ends of a distributed flow use the same provider - Add AbpIdentityTokenProviderOptions.UseAbpTokenProviders to opt out on every hostpull/26113/head
41 changed files with 1761 additions and 231 deletions
@ -1,94 +0,0 @@ |
|||
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)).CheckErrors(); |
|||
|
|||
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); |
|||
} |
|||
} |
|||
@ -1,62 +0,0 @@ |
|||
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); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Removes the stored link-user token hash for <paramref name="user"/>,
|
|||
/// immediately invalidating any previously issued link-user token.
|
|||
/// </summary>
|
|||
public static Task<IdentityResult> RemoveLinkUserTokenAsync(this IdentityUserManager manager, IdentityUser user) |
|||
{ |
|||
return RemoveLinkUserTokenAsync(manager, user, LinkUserTokenProviderConsts.LinkUserTokenPurpose); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Removes the stored link-user token hash for <paramref name="user"/> and the given <paramref name="purpose"/>,
|
|||
/// immediately invalidating any previously issued link-user token for that purpose.
|
|||
/// </summary>
|
|||
public static Task<IdentityResult> RemoveLinkUserTokenAsync(this IdentityUserManager manager, IdentityUser user, string purpose) |
|||
{ |
|||
var name = LinkUserTokenProviderConsts.LinkUserTokenProviderName + ":" + purpose; |
|||
return manager.RemoveAuthenticationTokenAsync(user, AbpSingleActiveTokenProvider.InternalLoginProvider, name); |
|||
} |
|||
} |
|||
@ -0,0 +1,34 @@ |
|||
using Microsoft.AspNetCore.Identity; |
|||
using Volo.Abp.Identity; |
|||
using Volo.Abp.Identity.AspNetCore; |
|||
|
|||
namespace Microsoft.Extensions.DependencyInjection; |
|||
|
|||
public static class AbpIdentityBuilderExtensions |
|||
{ |
|||
/// <summary>
|
|||
/// The providers are written for <see cref="IdentityUser"/>, so the builder has to be one for that
|
|||
/// user type.
|
|||
/// </summary>
|
|||
public static IdentityBuilder AddAbpTokenProviders(this IdentityBuilder builder) |
|||
{ |
|||
builder |
|||
.AddTokenProvider<AbpDefaultTokenProvider>(TokenOptions.DefaultProvider) |
|||
.AddTokenProvider<AbpEmailTwoFactorTokenProvider>(TokenOptions.DefaultEmailProvider) |
|||
.AddTokenProvider<AbpPhoneNumberTwoFactorTokenProvider>(TokenOptions.DefaultPhoneProvider) |
|||
.AddTokenProvider<AuthenticatorTokenProvider<IdentityUser>>(TokenOptions.DefaultAuthenticatorProvider) |
|||
.AddTokenProvider<AbpPasswordResetTokenProvider>(AbpPasswordResetTokenProvider.ProviderName) |
|||
.AddTokenProvider<AbpEmailConfirmationTokenProvider>(AbpEmailConfirmationTokenProvider.ProviderName) |
|||
.AddTokenProvider<AbpChangeEmailTokenProvider>(AbpChangeEmailTokenProvider.ProviderName) |
|||
.AddTokenProvider<LinkUserTokenProvider>(LinkUserTokenProviderConsts.LinkUserTokenProviderName); |
|||
|
|||
builder.Services.Configure<IdentityOptions>(options => |
|||
{ |
|||
options.Tokens.PasswordResetTokenProvider = AbpPasswordResetTokenProvider.ProviderName; |
|||
options.Tokens.EmailConfirmationTokenProvider = AbpEmailConfirmationTokenProvider.ProviderName; |
|||
options.Tokens.ChangeEmailTokenProvider = AbpChangeEmailTokenProvider.ProviderName; |
|||
}); |
|||
|
|||
return builder; |
|||
} |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
namespace Volo.Abp.Identity; |
|||
|
|||
public class AbpIdentityTokenProviderOptions |
|||
{ |
|||
/// <summary>
|
|||
/// Has to be set with <c>PreConfigure</c>: it is read while the token providers are being registered.
|
|||
/// </summary>
|
|||
public bool UseAbpTokenProviders { get; set; } = true; |
|||
} |
|||
@ -1,9 +1,8 @@ |
|||
using System; |
|||
using Microsoft.AspNetCore.Identity; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
public class AbpChangeEmailTokenProviderOptions : DataProtectionTokenProviderOptions |
|||
public class AbpChangeEmailTokenProviderOptions : AbpDataProtectionTokenProviderOptions |
|||
{ |
|||
public AbpChangeEmailTokenProviderOptions() |
|||
{ |
|||
@ -0,0 +1,18 @@ |
|||
using System; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
/// <summary>
|
|||
/// Stands in for ASP.NET Core's <c>DataProtectionTokenProviderOptions</c>, which is only available
|
|||
/// through the ASP.NET Core shared framework.
|
|||
/// </summary>
|
|||
public abstract class AbpDataProtectionTokenProviderOptions |
|||
{ |
|||
/// <summary>
|
|||
/// Also the DataProtection purpose, so two providers with different names cannot validate each
|
|||
/// other's tokens.
|
|||
/// </summary>
|
|||
public string Name { get; set; } = "DataProtectorTokenProvider"; |
|||
|
|||
public TimeSpan TokenLifespan { get; set; } = TimeSpan.FromDays(1); |
|||
} |
|||
@ -1,9 +1,8 @@ |
|||
using System; |
|||
using Microsoft.AspNetCore.Identity; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
public class AbpEmailConfirmationTokenProviderOptions : DataProtectionTokenProviderOptions |
|||
public class AbpEmailConfirmationTokenProviderOptions : AbpDataProtectionTokenProviderOptions |
|||
{ |
|||
public AbpEmailConfirmationTokenProviderOptions() |
|||
{ |
|||
@ -1,9 +1,8 @@ |
|||
using System; |
|||
using Microsoft.AspNetCore.Identity; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
public class AbpLinkUserTokenProviderOptions : DataProtectionTokenProviderOptions |
|||
public class AbpLinkUserTokenProviderOptions : AbpDataProtectionTokenProviderOptions |
|||
{ |
|||
public AbpLinkUserTokenProviderOptions() |
|||
{ |
|||
@ -1,9 +1,8 @@ |
|||
using System; |
|||
using Microsoft.AspNetCore.Identity; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
public class AbpPasswordResetTokenProviderOptions : DataProtectionTokenProviderOptions |
|||
public class AbpPasswordResetTokenProviderOptions : AbpDataProtectionTokenProviderOptions |
|||
{ |
|||
public AbpPasswordResetTokenProviderOptions() |
|||
{ |
|||
@ -0,0 +1,200 @@ |
|||
using System; |
|||
using System.IO; |
|||
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.Logging.Abstractions; |
|||
using Microsoft.Extensions.Options; |
|||
using Volo.Abp.Domain.Repositories; |
|||
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 token
|
|||
/// invalidates the one before it for the same user, provider and purpose.
|
|||
/// <para>
|
|||
/// Re-implements ASP.NET Core's <c>DataProtectorTokenProvider</c>, which is only available through the
|
|||
/// ASP.NET Core shared framework, and stays byte compatible with it.
|
|||
/// </para>
|
|||
/// </summary>
|
|||
public abstract class AbpSingleActiveTokenProvider : IUserTwoFactorTokenProvider<IdentityUser> |
|||
{ |
|||
/// <summary>
|
|||
/// The internal login provider name used to store token hashes among the user's tokens.
|
|||
/// 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 static readonly Encoding PayloadEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); |
|||
|
|||
protected AbpDataProtectionTokenProviderOptions Options { get; } |
|||
|
|||
protected IDataProtector Protector { get; } |
|||
|
|||
protected ILogger<AbpSingleActiveTokenProvider> Logger { get; } |
|||
|
|||
protected IIdentityUserRepository UserRepository { get; } |
|||
|
|||
protected ICancellationTokenProvider CancellationTokenProvider { get; } |
|||
|
|||
public string Name => Options.Name; |
|||
|
|||
protected AbpSingleActiveTokenProvider( |
|||
IDataProtectionProvider dataProtectionProvider, |
|||
IOptions<AbpDataProtectionTokenProviderOptions> options, |
|||
ILogger<AbpSingleActiveTokenProvider> logger, |
|||
IIdentityUserRepository userRepository, |
|||
ICancellationTokenProvider cancellationTokenProvider) |
|||
{ |
|||
Check.NotNull(dataProtectionProvider, nameof(dataProtectionProvider)); |
|||
Check.NotNull(options, nameof(options)); |
|||
|
|||
Options = options.Value; |
|||
Protector = dataProtectionProvider.CreateProtector(Options.Name ?? "DataProtectorTokenProvider"); |
|||
Logger = logger ?? NullLogger<AbpSingleActiveTokenProvider>.Instance; |
|||
UserRepository = userRepository; |
|||
CancellationTokenProvider = cancellationTokenProvider; |
|||
} |
|||
|
|||
public virtual async Task<string> GenerateAsync(string purpose, UserManager<IdentityUser> manager, IdentityUser user) |
|||
{ |
|||
Check.NotNull(user, nameof(user)); |
|||
|
|||
var token = await ProtectAsync(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)).CheckErrors(); |
|||
|
|||
return token; |
|||
} |
|||
|
|||
public virtual async Task<bool> ValidateAsync(string purpose, string token, UserManager<IdentityUser> manager, IdentityUser user) |
|||
{ |
|||
if (!await UnprotectAsync(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) |
|||
{ |
|||
Logger.LogDebug("No stored hash for the '{ProviderName}' token and the '{Purpose}' purpose. It was never issued, it was removed, or the token came from a provider that does not store one.", Options.Name, purpose); |
|||
return false; |
|||
} |
|||
|
|||
var inputHash = ComputeSha256Hash(token); |
|||
try |
|||
{ |
|||
var storedHashBytes = Convert.FromHexString(storedHash); |
|||
var inputHashBytes = Convert.FromHexString(inputHash); |
|||
return CryptographicOperations.FixedTimeEquals(storedHashBytes, inputHashBytes); |
|||
} |
|||
catch (FormatException) |
|||
{ |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
public virtual Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<IdentityUser> manager, IdentityUser user) |
|||
{ |
|||
return Task.FromResult(false); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Keep in sync with ASP.NET Core's <c>DataProtectorTokenProvider</c>: the two have to stay
|
|||
/// interchangeable under the same provider name.
|
|||
/// </summary>
|
|||
protected virtual async Task<string> ProtectAsync(string purpose, UserManager<IdentityUser> manager, IdentityUser user) |
|||
{ |
|||
var stream = new MemoryStream(); |
|||
var userId = await manager.GetUserIdAsync(user); |
|||
|
|||
using (var writer = new BinaryWriter(stream, PayloadEncoding, leaveOpen: true)) |
|||
{ |
|||
// Not IClock: the payload has to stay byte compatible with ASP.NET Core's.
|
|||
writer.Write(DateTimeOffset.UtcNow.UtcTicks); |
|||
writer.Write(userId); |
|||
writer.Write(purpose ?? ""); |
|||
writer.Write(manager.SupportsUserSecurityStamp ? await manager.GetSecurityStampAsync(user) ?? "" : ""); |
|||
} |
|||
|
|||
return Convert.ToBase64String(Protector.Protect(stream.ToArray())); |
|||
} |
|||
|
|||
protected virtual async Task<bool> UnprotectAsync(string purpose, string token, UserManager<IdentityUser> manager, IdentityUser user) |
|||
{ |
|||
try |
|||
{ |
|||
var stream = new MemoryStream(Protector.Unprotect(Convert.FromBase64String(token))); |
|||
using (var reader = new BinaryReader(stream, PayloadEncoding, leaveOpen: true)) |
|||
{ |
|||
var creationTime = new DateTimeOffset(reader.ReadInt64(), TimeSpan.Zero); |
|||
if (creationTime + Options.TokenLifespan < DateTimeOffset.UtcNow) |
|||
{ |
|||
Logger.LogDebug("Invalid expiration time for the '{ProviderName}' token.", Options.Name); |
|||
return false; |
|||
} |
|||
|
|||
if (reader.ReadString() != await manager.GetUserIdAsync(user)) |
|||
{ |
|||
Logger.LogDebug("User ID of the '{ProviderName}' token does not match the current user.", Options.Name); |
|||
return false; |
|||
} |
|||
|
|||
var tokenPurpose = reader.ReadString(); |
|||
if (!string.Equals(tokenPurpose, purpose)) |
|||
{ |
|||
Logger.LogDebug("Purpose of the '{ProviderName}' token is '{TokenPurpose}' but '{Purpose}' was expected.", Options.Name, tokenPurpose, purpose); |
|||
return false; |
|||
} |
|||
|
|||
var stamp = reader.ReadString(); |
|||
if (reader.PeekChar() != -1) |
|||
{ |
|||
Logger.LogDebug("Unexpected data after the end of the '{ProviderName}' token payload.", Options.Name); |
|||
return false; |
|||
} |
|||
|
|||
if (manager.SupportsUserSecurityStamp) |
|||
{ |
|||
if (stamp == await manager.GetSecurityStampAsync(user)) |
|||
{ |
|||
return true; |
|||
} |
|||
|
|||
Logger.LogDebug("Security stamp of the '{ProviderName}' token does not match the current one.", Options.Name); |
|||
return false; |
|||
} |
|||
|
|||
if (stamp == "") |
|||
{ |
|||
return true; |
|||
} |
|||
|
|||
Logger.LogDebug("Security stamp of the '{ProviderName}' token is not empty.", Options.Name); |
|||
return false; |
|||
} |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
Logger.LogDebug(ex, "Could not read the '{ProviderName}' token. It was protected under another provider name, key ring or application name, or the payload is not a token this provider produced.", Options.Name); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
protected virtual string ComputeSha256Hash(string input) |
|||
{ |
|||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input)); |
|||
return Convert.ToHexString(bytes); |
|||
} |
|||
} |
|||
@ -0,0 +1,110 @@ |
|||
using System.Collections.Generic; |
|||
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"/>.
|
|||
/// </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) |
|||
{ |
|||
return RemoveStoredTokenAsync( |
|||
manager, |
|||
user, |
|||
manager.Options.Tokens.PasswordResetTokenProvider, |
|||
UserManager<IdentityUser>.ResetPasswordTokenPurpose); |
|||
} |
|||
|
|||
/// <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) |
|||
{ |
|||
return RemoveStoredTokenAsync( |
|||
manager, |
|||
user, |
|||
manager.Options.Tokens.EmailConfirmationTokenProvider, |
|||
UserManager<IdentityUser>.ConfirmEmailTokenPurpose); |
|||
} |
|||
|
|||
/// <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) |
|||
{ |
|||
return RemoveStoredTokenAsync( |
|||
manager, |
|||
user, |
|||
manager.Options.Tokens.ChangeEmailTokenProvider, |
|||
UserManager<IdentityUser>.GetChangeEmailTokenPurpose(newEmail)); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Removes the stored link-user token hash for <paramref name="user"/>,
|
|||
/// immediately invalidating any previously issued link-user token.
|
|||
/// </summary>
|
|||
public static Task<IdentityResult> RemoveLinkUserTokenAsync(this IdentityUserManager manager, IdentityUser user) |
|||
{ |
|||
return RemoveLinkUserTokenAsync(manager, user, LinkUserTokenProviderConsts.LinkUserTokenPurpose); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Removes the stored link-user token hash for <paramref name="user"/> and the given <paramref name="purpose"/>,
|
|||
/// immediately invalidating any previously issued link-user token for that purpose.
|
|||
/// </summary>
|
|||
public static Task<IdentityResult> RemoveLinkUserTokenAsync(this IdentityUserManager manager, IdentityUser user, string purpose) |
|||
{ |
|||
return RemoveStoredTokenAsync( |
|||
manager, |
|||
user, |
|||
LinkUserTokenProviderConsts.LinkUserTokenProviderName, |
|||
purpose); |
|||
} |
|||
|
|||
private static Task<IdentityResult> RemoveStoredTokenAsync( |
|||
IdentityUserManager manager, |
|||
IdentityUser user, |
|||
string providerKey, |
|||
string purpose) |
|||
{ |
|||
return manager.RemoveAuthenticationTokenAsync( |
|||
user, |
|||
AbpSingleActiveTokenProvider.InternalLoginProvider, |
|||
GetStoredTokenName(manager, providerKey, purpose)); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// The hash is stored under the provider's options <c>Name</c>, which is configurable and therefore
|
|||
/// not necessarily the key the provider is registered under.
|
|||
/// </summary>
|
|||
private static string GetStoredTokenName(IdentityUserManager manager, string providerKey, string purpose) |
|||
{ |
|||
var descriptor = manager.Options.Tokens.ProviderMap.GetOrDefault(providerKey); |
|||
var provider = descriptor?.ProviderInstance ?? (descriptor != null |
|||
? manager.ServiceProvider.GetService(descriptor.ProviderType) |
|||
: null); |
|||
|
|||
if (provider is not AbpSingleActiveTokenProvider singleActiveTokenProvider) |
|||
{ |
|||
throw new AbpException( |
|||
$"The '{providerKey}' token provider is not an {nameof(AbpSingleActiveTokenProvider)}, so it does not " + |
|||
$"store a token hash that can be removed. This happens when the key has no provider at all, when " + |
|||
$"the ABP token providers are turned off " + |
|||
$"through {nameof(AbpIdentityTokenProviderOptions)}.{nameof(AbpIdentityTokenProviderOptions.UseAbpTokenProviders)} " + |
|||
$"or when the key was re-registered with another provider, including one registered for a " + |
|||
$"second user type."); |
|||
} |
|||
|
|||
return singleActiveTokenProvider.Name + ":" + purpose; |
|||
} |
|||
} |
|||
@ -0,0 +1,217 @@ |
|||
using System; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.DataProtection; |
|||
using Microsoft.AspNetCore.Identity; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Microsoft.Extensions.Options; |
|||
using Shouldly; |
|||
using Volo.Abp.Modularity; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
public class AbpIdentityTokenProviderOptions_Tests |
|||
{ |
|||
[Fact] |
|||
public async Task Should_Register_Abp_Token_Providers_Without_The_AspNetCore_Module() |
|||
{ |
|||
var tokens = await GetTokenOptionsAsync<DomainOnlyModule>(); |
|||
|
|||
tokens.PasswordResetTokenProvider.ShouldBe(AbpPasswordResetTokenProvider.ProviderName); |
|||
tokens.EmailConfirmationTokenProvider.ShouldBe(AbpEmailConfirmationTokenProvider.ProviderName); |
|||
tokens.ChangeEmailTokenProvider.ShouldBe(AbpChangeEmailTokenProvider.ProviderName); |
|||
|
|||
tokens.ProviderMap[TokenOptions.DefaultProvider].ProviderType.ShouldBe(typeof(AbpDefaultTokenProvider)); |
|||
tokens.ProviderMap[TokenOptions.DefaultEmailProvider].ProviderType.ShouldBe(typeof(AbpEmailTwoFactorTokenProvider)); |
|||
tokens.ProviderMap[TokenOptions.DefaultPhoneProvider].ProviderType.ShouldBe(typeof(AbpPhoneNumberTwoFactorTokenProvider)); |
|||
tokens.ProviderMap[LinkUserTokenProviderConsts.LinkUserTokenProviderName].ProviderType.ShouldBe(typeof(LinkUserTokenProvider)); |
|||
|
|||
// Not replaced by ABP, but still has to be registered: the providers are listed one by one
|
|||
// instead of calling AddDefaultTokenProviders(), so a missing key would go unnoticed.
|
|||
tokens.ProviderMap[TokenOptions.DefaultAuthenticatorProvider].ProviderType |
|||
.ShouldBe(typeof(AuthenticatorTokenProvider<IdentityUser>)); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Resolve_The_Same_Providers_With_And_Without_The_AspNetCore_Module() |
|||
{ |
|||
var domainOnly = await GetTokenOptionsAsync<DomainOnlyModule>(); |
|||
var withAspNetCore = await GetTokenOptionsAsync<AspNetCoreModule>(); |
|||
|
|||
withAspNetCore.PasswordResetTokenProvider.ShouldBe(domainOnly.PasswordResetTokenProvider); |
|||
withAspNetCore.EmailConfirmationTokenProvider.ShouldBe(domainOnly.EmailConfirmationTokenProvider); |
|||
withAspNetCore.ChangeEmailTokenProvider.ShouldBe(domainOnly.ChangeEmailTokenProvider); |
|||
|
|||
// Both directions: iterating one map alone would pass while the other side has extra keys.
|
|||
withAspNetCore.ProviderMap.Keys.OrderBy(x => x).ShouldBe(domainOnly.ProviderMap.Keys.OrderBy(x => x)); |
|||
|
|||
foreach (var (name, descriptor) in domainOnly.ProviderMap) |
|||
{ |
|||
withAspNetCore.ProviderMap[name].ProviderType.ShouldBe(descriptor.ProviderType); |
|||
} |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(typeof(NoAbpTokenProvidersModule))] |
|||
[InlineData(typeof(DomainOnlyNoAbpTokenProvidersModule))] |
|||
public async Task Should_Register_No_Token_Provider_When_Opted_Out(Type moduleType) |
|||
{ |
|||
// Registering something here for one host shape and nothing for the other would put the two
|
|||
// ends of a link back on different providers, which is the failure the flag has to avoid.
|
|||
var tokens = await GetTokenOptionsAsync(moduleType); |
|||
|
|||
tokens.ProviderMap.ShouldBeEmpty(); |
|||
|
|||
tokens.PasswordResetTokenProvider.ShouldBe(TokenOptions.DefaultProvider); |
|||
tokens.EmailConfirmationTokenProvider.ShouldBe(TokenOptions.DefaultProvider); |
|||
tokens.ChangeEmailTokenProvider.ShouldBe(TokenOptions.DefaultProvider); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(typeof(AspNetCoreStockTokenProvidersModule))] |
|||
[InlineData(typeof(DomainOnlyStockTokenProvidersModule))] |
|||
public async Task Should_Let_The_Application_Register_The_AspNetCore_Providers_When_Opted_Out(Type moduleType) |
|||
{ |
|||
var tokens = await GetTokenOptionsAsync(moduleType); |
|||
|
|||
tokens.ProviderMap[TokenOptions.DefaultProvider].ProviderType.ShouldBe(typeof(DataProtectorTokenProvider<IdentityUser>)); |
|||
tokens.ProviderMap[TokenOptions.DefaultEmailProvider].ProviderType.ShouldBe(typeof(EmailTokenProvider<IdentityUser>)); |
|||
tokens.ProviderMap[TokenOptions.DefaultPhoneProvider].ProviderType.ShouldBe(typeof(PhoneNumberTokenProvider<IdentityUser>)); |
|||
tokens.ProviderMap[TokenOptions.DefaultAuthenticatorProvider].ProviderType.ShouldBe(typeof(AuthenticatorTokenProvider<IdentityUser>)); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Let_The_Application_Override_A_Provider() |
|||
{ |
|||
var tokens = await GetTokenOptionsAsync<CustomPasswordResetProviderModule>(); |
|||
|
|||
tokens.ProviderMap[AbpPasswordResetTokenProvider.ProviderName].ProviderType |
|||
.ShouldBe(typeof(DataProtectorTokenProvider<IdentityUser>)); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Register_DataProtection_For_The_Token_Providers() |
|||
{ |
|||
// UserManager instantiates every provider in Tokens.ProviderMap when it is resolved, and the
|
|||
// DataProtection based ones need IDataProtectionProvider. Hosts that never issue a token, such
|
|||
// as a DbMigrator console application, do not register it themselves.
|
|||
using var application = await AbpApplicationFactory.CreateAsync<DomainOnlyModule>(); |
|||
await application.InitializeAsync(); |
|||
|
|||
application.ServiceProvider.GetService<IDataProtectionProvider>().ShouldNotBeNull(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Register_DataProtection_When_Opted_Out() |
|||
{ |
|||
// Opting out is not a reason to take it away: the providers the application registers instead
|
|||
// are typically the ASP.NET Core ones, and DataProtectorTokenProvider needs it just as much.
|
|||
using var application = await AbpApplicationFactory.CreateAsync<NoAbpTokenProvidersModule>(); |
|||
await application.InitializeAsync(); |
|||
|
|||
application.ServiceProvider.GetService<IDataProtectionProvider>().ShouldNotBeNull(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task A_Leftover_AddDefaultTokenProviders_Should_Override_Only_The_Keys_It_Registers() |
|||
{ |
|||
// Application actions run after the framework registration, so a host that also calls
|
|||
// AddDefaultTokenProviders() ends up with the stock providers on the keys that call covers
|
|||
// and the ABP ones everywhere else.
|
|||
var tokens = await GetTokenOptionsAsync<LeftoverDefaultTokenProvidersModule>(); |
|||
|
|||
tokens.ProviderMap[TokenOptions.DefaultProvider].ProviderType.ShouldBe(typeof(DataProtectorTokenProvider<IdentityUser>)); |
|||
tokens.ProviderMap[TokenOptions.DefaultEmailProvider].ProviderType.ShouldBe(typeof(EmailTokenProvider<IdentityUser>)); |
|||
tokens.ProviderMap[TokenOptions.DefaultPhoneProvider].ProviderType.ShouldBe(typeof(PhoneNumberTokenProvider<IdentityUser>)); |
|||
|
|||
tokens.PasswordResetTokenProvider.ShouldBe(AbpPasswordResetTokenProvider.ProviderName); |
|||
tokens.ProviderMap[AbpPasswordResetTokenProvider.ProviderName].ProviderType.ShouldBe(typeof(AbpPasswordResetTokenProvider)); |
|||
} |
|||
|
|||
private static Task<TokenOptions> GetTokenOptionsAsync<TModule>() |
|||
where TModule : IAbpModule |
|||
{ |
|||
return GetTokenOptionsAsync(typeof(TModule)); |
|||
} |
|||
|
|||
private static async Task<TokenOptions> GetTokenOptionsAsync(Type moduleType) |
|||
{ |
|||
using var application = await AbpApplicationFactory.CreateAsync(moduleType); |
|||
await application.InitializeAsync(); |
|||
return application.ServiceProvider.GetRequiredService<IOptions<IdentityOptions>>().Value.Tokens; |
|||
} |
|||
} |
|||
|
|||
[DependsOn(typeof(AbpIdentityDomainModule))] |
|||
public class DomainOnlyModule : AbpModule |
|||
{ |
|||
} |
|||
|
|||
[DependsOn(typeof(AbpIdentityAspNetCoreModule))] |
|||
public class AspNetCoreModule : AbpModule |
|||
{ |
|||
public override void PreConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
PreConfigure<AbpIdentityAspNetCoreOptions>(options => options.ConfigureAuthentication = false); |
|||
} |
|||
} |
|||
|
|||
[DependsOn(typeof(AbpIdentityDomainModule))] |
|||
public class LeftoverDefaultTokenProvidersModule : AbpModule |
|||
{ |
|||
public override void PreConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
PreConfigure<IdentityBuilder>(builder => builder.AddDefaultTokenProviders()); |
|||
} |
|||
} |
|||
|
|||
[DependsOn(typeof(AbpIdentityDomainModule))] |
|||
public class DomainOnlyNoAbpTokenProvidersModule : AbpModule |
|||
{ |
|||
public override void PreConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
PreConfigure<AbpIdentityTokenProviderOptions>(options => options.UseAbpTokenProviders = false); |
|||
} |
|||
} |
|||
|
|||
[DependsOn(typeof(AbpIdentityAspNetCoreModule))] |
|||
public class NoAbpTokenProvidersModule : AbpModule |
|||
{ |
|||
public override void PreConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
PreConfigure<AbpIdentityAspNetCoreOptions>(options => options.ConfigureAuthentication = false); |
|||
PreConfigure<AbpIdentityTokenProviderOptions>(options => options.UseAbpTokenProviders = false); |
|||
} |
|||
} |
|||
|
|||
[DependsOn(typeof(AbpIdentityAspNetCoreModule))] |
|||
public class AspNetCoreStockTokenProvidersModule : AbpModule |
|||
{ |
|||
public override void PreConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
PreConfigure<AbpIdentityAspNetCoreOptions>(options => options.ConfigureAuthentication = false); |
|||
PreConfigure<AbpIdentityTokenProviderOptions>(options => options.UseAbpTokenProviders = false); |
|||
PreConfigure<IdentityBuilder>(builder => builder.AddDefaultTokenProviders()); |
|||
} |
|||
} |
|||
|
|||
[DependsOn(typeof(AbpIdentityDomainModule))] |
|||
public class DomainOnlyStockTokenProvidersModule : AbpModule |
|||
{ |
|||
public override void PreConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
PreConfigure<AbpIdentityTokenProviderOptions>(options => options.UseAbpTokenProviders = false); |
|||
PreConfigure<IdentityBuilder>(builder => builder.AddDefaultTokenProviders()); |
|||
} |
|||
} |
|||
|
|||
[DependsOn(typeof(AbpIdentityDomainModule))] |
|||
public class CustomPasswordResetProviderModule : AbpModule |
|||
{ |
|||
public override void PreConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
PreConfigure<IdentityBuilder>(builder => |
|||
builder.AddTokenProvider<DataProtectorTokenProvider<IdentityUser>>(AbpPasswordResetTokenProvider.ProviderName)); |
|||
} |
|||
} |
|||
@ -0,0 +1,89 @@ |
|||
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 Shouldly; |
|||
using Volo.Abp.Uow; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
/// <summary>
|
|||
/// Pins the payload of <see cref="AbpSingleActiveTokenProvider"/> to ASP.NET Core's
|
|||
/// <see cref="DataProtectorTokenProvider{TUser}"/>, which it re-implements rather than derives from:
|
|||
/// a token produced by one has to be accepted by the other under the same provider name.
|
|||
/// </summary>
|
|||
public class AbpSingleActiveTokenProvider_Compatibility_Tests : AbpIdentityAspNetCoreTestBase |
|||
{ |
|||
private const string Purpose = "ResetPassword"; |
|||
|
|||
private readonly IIdentityUserRepository _userRepository; |
|||
private readonly IdentityUserManager _userManager; |
|||
private readonly IdentityTestData _testData; |
|||
private readonly IUnitOfWorkManager _unitOfWorkManager; |
|||
|
|||
public AbpSingleActiveTokenProvider_Compatibility_Tests() |
|||
{ |
|||
_userRepository = GetRequiredService<IIdentityUserRepository>(); |
|||
_userManager = GetRequiredService<IdentityUserManager>(); |
|||
_testData = GetRequiredService<IdentityTestData>(); |
|||
_unitOfWorkManager = GetRequiredService<IUnitOfWorkManager>(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task AspNetCore_Provider_Should_Validate_A_Token_Generated_By_Abp() |
|||
{ |
|||
using var uow = _unitOfWorkManager.Begin(); |
|||
|
|||
var user = await _userRepository.GetAsync(_testData.UserJohnId); |
|||
var token = await _userManager.GeneratePasswordResetTokenAsync(user); |
|||
|
|||
user = await _userRepository.GetAsync(_testData.UserJohnId); |
|||
(await CreateAspNetCoreProvider().ValidateAsync(Purpose, token, _userManager, user)).ShouldBeTrue(); |
|||
|
|||
await uow.CompleteAsync(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Abp_Provider_Should_Validate_A_Token_Generated_By_AspNetCore() |
|||
{ |
|||
using var uow = _unitOfWorkManager.Begin(); |
|||
|
|||
var user = await _userRepository.GetAsync(_testData.UserJohnId); |
|||
var token = await CreateAspNetCoreProvider().GenerateAsync(Purpose, _userManager, user); |
|||
|
|||
// The ABP provider additionally requires the stored hash the generating side would have written.
|
|||
(await _userManager.SetAuthenticationTokenAsync( |
|||
user, |
|||
AbpSingleActiveTokenProvider.InternalLoginProvider, |
|||
AbpPasswordResetTokenProvider.ProviderName + ":" + Purpose, |
|||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))))).Succeeded.ShouldBeTrue(); |
|||
|
|||
user = await _userRepository.GetAsync(_testData.UserJohnId); |
|||
(await _userManager.VerifyUserTokenAsync( |
|||
user, |
|||
AbpPasswordResetTokenProvider.ProviderName, |
|||
Purpose, |
|||
token)).ShouldBeTrue(); |
|||
|
|||
await uow.CompleteAsync(); |
|||
} |
|||
|
|||
private DataProtectorTokenProvider<IdentityUser> CreateAspNetCoreProvider() |
|||
{ |
|||
var abpOptions = GetRequiredService<IOptions<AbpPasswordResetTokenProviderOptions>>().Value; |
|||
|
|||
return new DataProtectorTokenProvider<IdentityUser>( |
|||
GetRequiredService<IDataProtectionProvider>(), |
|||
Microsoft.Extensions.Options.Options.Create(new DataProtectionTokenProviderOptions |
|||
{ |
|||
Name = abpOptions.Name, |
|||
TokenLifespan = abpOptions.TokenLifespan |
|||
}), |
|||
GetRequiredService<ILogger<DataProtectorTokenProvider<IdentityUser>>>()); |
|||
} |
|||
} |
|||
@ -0,0 +1,52 @@ |
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Microsoft.Extensions.Options; |
|||
using Shouldly; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
/// <summary>
|
|||
/// The validating host decides whether a token is expired, so a changed default silently shortens or
|
|||
/// extends every outstanding link across the solution. Pinned here, resolved through the options
|
|||
/// pipeline so that a stray <c>Configure</c> in the module is caught as well.
|
|||
/// </summary>
|
|||
public class TokenProviderLifespan_Tests |
|||
{ |
|||
[Fact] |
|||
public async Task Default_Lifespans_Should_Not_Change() |
|||
{ |
|||
using var application = await AbpApplicationFactory.CreateAsync<DomainOnlyModule>(); |
|||
await application.InitializeAsync(); |
|||
|
|||
LifespanOf<AbpPasswordResetTokenProviderOptions>(application).ShouldBe(TimeSpan.FromHours(2)); |
|||
LifespanOf<AbpEmailConfirmationTokenProviderOptions>(application).ShouldBe(TimeSpan.FromHours(2)); |
|||
LifespanOf<AbpChangeEmailTokenProviderOptions>(application).ShouldBe(TimeSpan.FromHours(2)); |
|||
LifespanOf<AbpDefaultTokenProviderOptions>(application).ShouldBe(TimeSpan.FromMinutes(10)); |
|||
LifespanOf<AbpLinkUserTokenProviderOptions>(application).ShouldBe(TimeSpan.FromMinutes(10)); |
|||
|
|||
application.ServiceProvider.GetRequiredService<IOptions<AbpEmailTwoFactorTokenProviderOptions>>() |
|||
.Value.TokenLifespan.ShouldBe(TimeSpan.FromMinutes(3)); |
|||
application.ServiceProvider.GetRequiredService<IOptions<AbpPhoneNumberTwoFactorTokenProviderOptions>>() |
|||
.Value.TokenLifespan.ShouldBe(TimeSpan.FromMinutes(3)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void The_Shared_Default_Should_Match_The_AspNetCore_One() |
|||
{ |
|||
// A provider that does not set its own lifespan has to land on the ASP.NET Core defaults.
|
|||
new UnconfiguredTokenProviderOptions().Name.ShouldBe("DataProtectorTokenProvider"); |
|||
new UnconfiguredTokenProviderOptions().TokenLifespan.ShouldBe(TimeSpan.FromDays(1)); |
|||
} |
|||
|
|||
private static TimeSpan LifespanOf<TOptions>(IAbpApplication application) |
|||
where TOptions : AbpDataProtectionTokenProviderOptions, new() |
|||
{ |
|||
return application.ServiceProvider.GetRequiredService<IOptions<TOptions>>().Value.TokenLifespan; |
|||
} |
|||
|
|||
private class UnconfiguredTokenProviderOptions : AbpDataProtectionTokenProviderOptions |
|||
{ |
|||
} |
|||
} |
|||
@ -0,0 +1,32 @@ |
|||
using Microsoft.AspNetCore.Identity; |
|||
using Shouldly; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Identity.AspNetCore; |
|||
|
|||
/// <summary>
|
|||
/// The options <c>Name</c> is the Data Protection purpose and the stored-token key prefix, so changing
|
|||
/// it silently invalidates every token issued before the change, on every host at once. Pinned here.
|
|||
/// </summary>
|
|||
public class TokenProviderOptionsName_Tests |
|||
{ |
|||
[Fact] |
|||
public void Provider_Names_Should_Not_Change() |
|||
{ |
|||
new AbpDefaultTokenProviderOptions().Name.ShouldBe(TokenOptions.DefaultProvider); |
|||
new AbpPasswordResetTokenProviderOptions().Name.ShouldBe(AbpPasswordResetTokenProvider.ProviderName); |
|||
new AbpEmailConfirmationTokenProviderOptions().Name.ShouldBe(AbpEmailConfirmationTokenProvider.ProviderName); |
|||
new AbpChangeEmailTokenProviderOptions().Name.ShouldBe(AbpChangeEmailTokenProvider.ProviderName); |
|||
new AbpLinkUserTokenProviderOptions().Name.ShouldBe(LinkUserTokenProviderConsts.LinkUserTokenProviderName); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Provider_Name_Constants_Should_Not_Change() |
|||
{ |
|||
AbpPasswordResetTokenProvider.ProviderName.ShouldBe("AbpPasswordReset"); |
|||
AbpEmailConfirmationTokenProvider.ProviderName.ShouldBe("AbpEmailConfirmation"); |
|||
AbpChangeEmailTokenProvider.ProviderName.ShouldBe("AbpChangeEmail"); |
|||
AbpSingleActiveTokenProvider.InternalLoginProvider.ShouldBe("[AbpSingleActiveToken]"); |
|||
LinkUserTokenProviderConsts.LinkUserTokenProviderName.ShouldBe("AbpLinkUser"); |
|||
} |
|||
} |
|||
@ -0,0 +1,388 @@ |
|||
using System; |
|||
using System.Security.Cryptography; |
|||
using System.Text; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Identity; |
|||
using Microsoft.EntityFrameworkCore; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Microsoft.Extensions.Options; |
|||
using Shouldly; |
|||
using Volo.Abp.Autofac; |
|||
using Volo.Abp.Data; |
|||
using Volo.Abp.Domain.Repositories; |
|||
using Volo.Abp.EntityFrameworkCore; |
|||
using Volo.Abp.EntityFrameworkCore.Sqlite; |
|||
using Volo.Abp.Identity.AspNetCore; |
|||
using Volo.Abp.Modularity; |
|||
using Volo.Abp.Uow; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Identity.EntityFrameworkCore; |
|||
|
|||
/// <summary>
|
|||
/// The Entity Framework Core run of the shared cross-host suite, plus the cases that do not depend on
|
|||
/// the persistence provider and therefore only need to run once.
|
|||
/// </summary>
|
|||
public class CrossHostTokenProvider_Tests |
|||
: CrossHostTokenProvider_Tests<CrossHostGeneratorHostModule, CrossHostValidatorHostModule> |
|||
{ |
|||
protected override Type ValidatorOnlyModuleType => typeof(AbpIdentityAspNetCoreModule); |
|||
|
|||
protected override IDisposable CreateSharedDatabase() |
|||
{ |
|||
var database = new AbpUnitTestSqliteDatabase(); |
|||
database.CreateTables( |
|||
new IdentityDbContext(new DbContextOptionsBuilder<IdentityDbContext>().UseSqlite(database.ConnectionString).Options)); |
|||
|
|||
CrossHostTokenProviderTestModuleBase.ConnectionString = database.ConnectionString; |
|||
return database; |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task A_Token_Generated_By_The_AspNetCore_Providers_Should_Not_Validate_Against_The_Abp_Ones() |
|||
{ |
|||
// A different options Name means a different Data Protection purpose, so the validating side
|
|||
// cannot even unprotect the payload.
|
|||
var userId = await CreateUserAsync(); |
|||
|
|||
using var optedOutHost = await CreateHostAsync<OptedOutGeneratorHostModule>(); |
|||
try |
|||
{ |
|||
var token = await WithUowAsync(optedOutHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
var identityOptions = sp.GetRequiredService<IOptions<IdentityOptions>>().Value; |
|||
identityOptions.Tokens.PasswordResetTokenProvider.ShouldBe(TokenOptions.DefaultProvider); |
|||
return await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId)); |
|||
}); |
|||
|
|||
var verified = await WithUowAsync(ValidatorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
var identityOptions = sp.GetRequiredService<IOptions<IdentityOptions>>().Value; |
|||
var user = await userManager.GetByIdAsync(userId); |
|||
|
|||
// Store the hash the ABP provider looks for, so a missing hash cannot be the reason
|
|||
// this fails. What is left is the protector purpose, which differs with the options Name.
|
|||
var storedName = identityOptions.Tokens.PasswordResetTokenProvider + ":" + ResetPasswordPurpose; |
|||
(await userManager.SetAuthenticationTokenAsync( |
|||
user, |
|||
AbpSingleActiveTokenProvider.InternalLoginProvider, |
|||
storedName, |
|||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))))).Succeeded.ShouldBeTrue(); |
|||
|
|||
user = await userManager.GetByIdAsync(userId); |
|||
(await userManager.GetAuthenticationTokenAsync( |
|||
user, |
|||
AbpSingleActiveTokenProvider.InternalLoginProvider, |
|||
storedName)).ShouldNotBeNull(); |
|||
|
|||
return await userManager.VerifyUserTokenAsync( |
|||
user, |
|||
identityOptions.Tokens.PasswordResetTokenProvider, |
|||
ResetPasswordPurpose, |
|||
token); |
|||
}); |
|||
|
|||
verified.ShouldBeFalse(); |
|||
} |
|||
finally |
|||
{ |
|||
await optedOutHost.ShutdownAsync(); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task A_Token_From_A_Host_That_Registers_The_AspNetCore_Providers_Itself_Should_Not_Validate() |
|||
{ |
|||
// A host that never loads the ASP.NET Core integration and wires up the stock providers itself.
|
|||
var userId = await CreateUserAsync(); |
|||
|
|||
using var stockHost = await CreateHostAsync<StockProviderGeneratorHostModule>(); |
|||
try |
|||
{ |
|||
var token = await WithUowAsync(stockHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
var identityOptions = sp.GetRequiredService<IOptions<IdentityOptions>>().Value; |
|||
identityOptions.Tokens.PasswordResetTokenProvider.ShouldBe(TokenOptions.DefaultProvider); |
|||
identityOptions.Tokens.ProviderMap[TokenOptions.DefaultProvider].ProviderType |
|||
.ShouldBe(typeof(DataProtectorTokenProvider<IdentityUser>)); |
|||
return await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId)); |
|||
}); |
|||
|
|||
var verified = await WithUowAsync(ValidatorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
var identityOptions = sp.GetRequiredService<IOptions<IdentityOptions>>().Value; |
|||
var user = await userManager.GetByIdAsync(userId); |
|||
|
|||
// Store the hash the ABP provider looks for, so a missing hash cannot be the reason
|
|||
// this fails. What is left is the protector purpose, which differs with the options Name.
|
|||
var storedName = identityOptions.Tokens.PasswordResetTokenProvider + ":" + ResetPasswordPurpose; |
|||
(await userManager.SetAuthenticationTokenAsync( |
|||
user, |
|||
AbpSingleActiveTokenProvider.InternalLoginProvider, |
|||
storedName, |
|||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))))).Succeeded.ShouldBeTrue(); |
|||
|
|||
user = await userManager.GetByIdAsync(userId); |
|||
(await userManager.GetAuthenticationTokenAsync( |
|||
user, |
|||
AbpSingleActiveTokenProvider.InternalLoginProvider, |
|||
storedName)).ShouldNotBeNull(); |
|||
|
|||
return await userManager.VerifyUserTokenAsync( |
|||
user, |
|||
identityOptions.Tokens.PasswordResetTokenProvider, |
|||
ResetPasswordPurpose, |
|||
token); |
|||
}); |
|||
|
|||
verified.ShouldBeFalse(); |
|||
} |
|||
finally |
|||
{ |
|||
await stockHost.ShutdownAsync(); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Removing_A_Stored_Token_Should_Follow_A_Customized_Provider_Name() |
|||
{ |
|||
var userId = await CreateUserAsync(); |
|||
|
|||
using var renamedHost = await CreateHostAsync<CustomProviderNameHostModule>(); |
|||
try |
|||
{ |
|||
var stillValid = await WithUowAsync(renamedHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
var identityOptions = sp.GetRequiredService<IOptions<IdentityOptions>>().Value; |
|||
|
|||
var token = await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId)); |
|||
|
|||
// The hash follows the options Name, while the provider stays on its registration key.
|
|||
// Without this the test would also pass if the renaming never took effect.
|
|||
var user = await userManager.GetByIdAsync(userId); |
|||
await sp.GetRequiredService<IIdentityUserRepository>().EnsureCollectionLoadedAsync(user, u => u.Tokens); |
|||
identityOptions.Tokens.PasswordResetTokenProvider.ShouldBe(AbpPasswordResetTokenProvider.ProviderName); |
|||
user.FindToken( |
|||
AbpSingleActiveTokenProvider.InternalLoginProvider, |
|||
CustomProviderNameHostModule.CustomName + ":" + ResetPasswordPurpose).ShouldNotBeNull(); |
|||
|
|||
(await userManager.RemovePasswordResetTokenAsync(await userManager.GetByIdAsync(userId))) |
|||
.Succeeded.ShouldBeTrue(); |
|||
|
|||
return await userManager.VerifyUserTokenAsync( |
|||
await userManager.GetByIdAsync(userId), |
|||
identityOptions.Tokens.PasswordResetTokenProvider, |
|||
ResetPasswordPurpose, |
|||
token); |
|||
}); |
|||
|
|||
stillValid.ShouldBeFalse(); |
|||
} |
|||
finally |
|||
{ |
|||
await renamedHost.ShutdownAsync(); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Removing_A_Stored_Token_Should_Fail_Loudly_When_The_Provider_Is_Not_Abps() |
|||
{ |
|||
// Reporting success here would leave the caller believing a token was revoked while the stock
|
|||
// provider, which keeps no server side state, happily keeps validating it.
|
|||
var userId = await CreateUserAsync(); |
|||
|
|||
using var stockHost = await CreateHostAsync<StockProviderGeneratorHostModule>(); |
|||
try |
|||
{ |
|||
await WithUowAsync(stockHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
var user = await userManager.GetByIdAsync(userId); |
|||
|
|||
await Should.ThrowAsync<AbpException>(async () => await userManager.RemovePasswordResetTokenAsync(user)); |
|||
return true; |
|||
}); |
|||
} |
|||
finally |
|||
{ |
|||
await stockHost.ShutdownAsync(); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task The_Validating_Host_Should_Decide_Whether_A_Token_Expired() |
|||
{ |
|||
var userId = await CreateUserAsync(); |
|||
|
|||
var token = await WithUowAsync(GeneratorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
return await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId)); |
|||
}); |
|||
|
|||
// The generating host used the default two hours; this one expires everything immediately.
|
|||
using var shortLivedValidator = await CreateHostAsync<ExpiredOnArrivalValidatorHostModule>(); |
|||
try |
|||
{ |
|||
var verified = await WithUowAsync(shortLivedValidator, async sp => |
|||
{ |
|||
sp.GetRequiredService<IOptions<AbpPasswordResetTokenProviderOptions>>().Value.TokenLifespan |
|||
.ShouldBe(TimeSpan.Zero); |
|||
sp.GetRequiredService<IOptions<DataProtectionTokenProviderOptions>>().Value.TokenLifespan |
|||
.ShouldBe(TimeSpan.FromDays(30)); |
|||
|
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
var identityOptions = sp.GetRequiredService<IOptions<IdentityOptions>>().Value; |
|||
return await userManager.VerifyUserTokenAsync( |
|||
await userManager.GetByIdAsync(userId), |
|||
identityOptions.Tokens.PasswordResetTokenProvider, |
|||
ResetPasswordPurpose, |
|||
token); |
|||
}); |
|||
|
|||
verified.ShouldBeFalse(); |
|||
} |
|||
finally |
|||
{ |
|||
await shortLivedValidator.ShutdownAsync(); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task The_Generating_Host_Lifespan_Should_Not_Decide_Whether_A_Token_Expired() |
|||
{ |
|||
var userId = await CreateUserAsync(); |
|||
|
|||
using var shortLivedGenerator = await CreateHostAsync<ExpiredOnArrivalGeneratorHostModule>(); |
|||
string token; |
|||
try |
|||
{ |
|||
token = await WithUowAsync(shortLivedGenerator, async sp => |
|||
{ |
|||
// Without this the test would also pass if the zero lifespan never took effect.
|
|||
sp.GetRequiredService<IOptions<AbpPasswordResetTokenProviderOptions>>().Value.TokenLifespan |
|||
.ShouldBe(TimeSpan.Zero); |
|||
|
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
return await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId)); |
|||
}); |
|||
} |
|||
finally |
|||
{ |
|||
await shortLivedGenerator.ShutdownAsync(); |
|||
} |
|||
|
|||
var verified = await WithUowAsync(ValidatorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
var identityOptions = sp.GetRequiredService<IOptions<IdentityOptions>>().Value; |
|||
return await userManager.VerifyUserTokenAsync( |
|||
await userManager.GetByIdAsync(userId), |
|||
identityOptions.Tokens.PasswordResetTokenProvider, |
|||
ResetPasswordPurpose, |
|||
token); |
|||
}); |
|||
|
|||
verified.ShouldBeTrue(); |
|||
} |
|||
} |
|||
|
|||
public abstract class EfCoreCrossHostTestModuleBase : CrossHostTokenProviderTestModuleBase |
|||
{ |
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
base.ConfigureServices(context); |
|||
|
|||
Configure<AbpDbContextOptions>(options => options.Configure(c => c.UseSqlite())); |
|||
context.Services.AddAlwaysDisableUnitOfWorkTransaction(); |
|||
} |
|||
} |
|||
|
|||
[DependsOn(typeof(AbpAutofacModule), typeof(AbpIdentityEntityFrameworkCoreModule), typeof(AbpEntityFrameworkCoreSqliteModule))] |
|||
public class CrossHostGeneratorHostModule : EfCoreCrossHostTestModuleBase |
|||
{ |
|||
} |
|||
|
|||
[DependsOn(typeof(AbpAutofacModule), typeof(AbpIdentityEntityFrameworkCoreModule), typeof(AbpIdentityAspNetCoreModule), typeof(AbpEntityFrameworkCoreSqliteModule))] |
|||
public class CrossHostValidatorHostModule : EfCoreCrossHostTestModuleBase |
|||
{ |
|||
public override void PreConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
PreConfigure<AbpIdentityAspNetCoreOptions>(options => options.ConfigureAuthentication = false); |
|||
} |
|||
} |
|||
|
|||
/// A host that opted out of the ABP providers and registered the ASP.NET Core ones instead.
|
|||
[DependsOn(typeof(AbpAutofacModule), typeof(AbpIdentityEntityFrameworkCoreModule), typeof(AbpIdentityAspNetCoreModule), typeof(AbpEntityFrameworkCoreSqliteModule))] |
|||
public class OptedOutGeneratorHostModule : EfCoreCrossHostTestModuleBase |
|||
{ |
|||
public override void PreConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
PreConfigure<AbpIdentityAspNetCoreOptions>(options => options.ConfigureAuthentication = false); |
|||
PreConfigure<AbpIdentityTokenProviderOptions>(options => options.UseAbpTokenProviders = false); |
|||
PreConfigure<IdentityBuilder>(builder => builder.AddDefaultTokenProviders()); |
|||
} |
|||
} |
|||
|
|||
/// Validator whose password-reset lifespan is zero. It also sets the ASP.NET Core
|
|||
/// <c>DataProtectionTokenProviderOptions</c> to 30 days, which must not rescue the token: the ABP
|
|||
/// providers read their own options type.
|
|||
[DependsOn(typeof(AbpAutofacModule), typeof(AbpIdentityEntityFrameworkCoreModule), typeof(AbpIdentityAspNetCoreModule), typeof(AbpEntityFrameworkCoreSqliteModule))] |
|||
public class ExpiredOnArrivalValidatorHostModule : EfCoreCrossHostTestModuleBase |
|||
{ |
|||
public override void PreConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
PreConfigure<AbpIdentityAspNetCoreOptions>(options => options.ConfigureAuthentication = false); |
|||
} |
|||
|
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
base.ConfigureServices(context); |
|||
|
|||
Configure<AbpPasswordResetTokenProviderOptions>(options => options.TokenLifespan = TimeSpan.Zero); |
|||
Configure<DataProtectionTokenProviderOptions>(options => options.TokenLifespan = TimeSpan.FromDays(30)); |
|||
} |
|||
} |
|||
|
|||
/// Generator whose password-reset lifespan is zero. The validating host still decides.
|
|||
[DependsOn(typeof(AbpAutofacModule), typeof(AbpIdentityEntityFrameworkCoreModule), typeof(AbpEntityFrameworkCoreSqliteModule))] |
|||
public class ExpiredOnArrivalGeneratorHostModule : EfCoreCrossHostTestModuleBase |
|||
{ |
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
base.ConfigureServices(context); |
|||
|
|||
Configure<AbpPasswordResetTokenProviderOptions>(options => options.TokenLifespan = TimeSpan.Zero); |
|||
} |
|||
} |
|||
|
|||
/// A host that never loads the ASP.NET Core integration and registers the stock providers itself.
|
|||
[DependsOn(typeof(AbpAutofacModule), typeof(AbpIdentityEntityFrameworkCoreModule), typeof(AbpEntityFrameworkCoreSqliteModule))] |
|||
public class StockProviderGeneratorHostModule : EfCoreCrossHostTestModuleBase |
|||
{ |
|||
public override void PreConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
PreConfigure<AbpIdentityTokenProviderOptions>(options => options.UseAbpTokenProviders = false); |
|||
PreConfigure<IdentityBuilder>(builder => builder.AddDefaultTokenProviders()); |
|||
} |
|||
} |
|||
|
|||
/// A host that renamed the password-reset provider. The stored hash then lives under that name and
|
|||
/// no longer matches the key the provider is registered under.
|
|||
[DependsOn(typeof(AbpAutofacModule), typeof(AbpIdentityEntityFrameworkCoreModule), typeof(AbpEntityFrameworkCoreSqliteModule))] |
|||
public class CustomProviderNameHostModule : EfCoreCrossHostTestModuleBase |
|||
{ |
|||
public const string CustomName = "CustomPasswordReset"; |
|||
|
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
base.ConfigureServices(context); |
|||
|
|||
Configure<AbpPasswordResetTokenProviderOptions>(options => options.Name = CustomName); |
|||
} |
|||
} |
|||
@ -0,0 +1,52 @@ |
|||
using System; |
|||
using Volo.Abp.Autofac; |
|||
using Volo.Abp.Identity.AspNetCore; |
|||
using Volo.Abp.Modularity; |
|||
using Volo.Abp.Uow; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Identity.MongoDB; |
|||
|
|||
[Collection(MongoTestCollection.Name)] |
|||
public class CrossHostTokenProvider_Tests |
|||
: CrossHostTokenProvider_Tests<MongoCrossHostGeneratorHostModule, MongoCrossHostValidatorHostModule> |
|||
{ |
|||
protected override Type ValidatorOnlyModuleType => typeof(AbpIdentityAspNetCoreModule); |
|||
|
|||
protected override IDisposable CreateSharedDatabase() |
|||
{ |
|||
CrossHostTokenProviderTestModuleBase.ConnectionString = MongoDbFixture.GetRandomConnectionString(); |
|||
return new NullDisposable(); |
|||
} |
|||
|
|||
private sealed class NullDisposable : IDisposable |
|||
{ |
|||
public void Dispose() |
|||
{ |
|||
} |
|||
} |
|||
} |
|||
|
|||
public abstract class MongoCrossHostTestModuleBase : CrossHostTokenProviderTestModuleBase |
|||
{ |
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
base.ConfigureServices(context); |
|||
|
|||
Configure<AbpUnitOfWorkDefaultOptions>(options => options.TransactionBehavior = UnitOfWorkTransactionBehavior.Disabled); |
|||
} |
|||
} |
|||
|
|||
[DependsOn(typeof(AbpAutofacModule), typeof(AbpIdentityMongoDbModule))] |
|||
public class MongoCrossHostGeneratorHostModule : MongoCrossHostTestModuleBase |
|||
{ |
|||
} |
|||
|
|||
[DependsOn(typeof(AbpAutofacModule), typeof(AbpIdentityMongoDbModule), typeof(AbpIdentityAspNetCoreModule))] |
|||
public class MongoCrossHostValidatorHostModule : MongoCrossHostTestModuleBase |
|||
{ |
|||
public override void PreConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
PreConfigure<AbpIdentityAspNetCoreOptions>(options => options.ConfigureAuthentication = false); |
|||
} |
|||
} |
|||
@ -0,0 +1,417 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using System.Xml.Linq; |
|||
using Microsoft.AspNetCore.DataProtection; |
|||
using Microsoft.AspNetCore.DataProtection.KeyManagement; |
|||
using Microsoft.AspNetCore.DataProtection.Repositories; |
|||
using Microsoft.AspNetCore.Identity; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Microsoft.Extensions.Options; |
|||
using Shouldly; |
|||
using Volo.Abp.Autofac; |
|||
using Volo.Abp.Data; |
|||
using Volo.Abp.Domain.Repositories; |
|||
using Volo.Abp.Identity.AspNetCore; |
|||
using Volo.Abp.Modularity; |
|||
using Volo.Abp.MultiTenancy; |
|||
using Volo.Abp.Uow; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Identity; |
|||
|
|||
/// <summary>
|
|||
/// Two independent ABP applications over one shared database and one shared key ring, only one of them
|
|||
/// loading the ASP.NET Core integration: a token generated on either has to validate on the other. The
|
|||
/// single-active hash goes through <see cref="IIdentityUserRepository"/>, so every persistence provider
|
|||
/// derives from this class and runs the same flows against its own database.
|
|||
/// </summary>
|
|||
public abstract class CrossHostTokenProvider_Tests<TGeneratorModule, TValidatorModule> : IAsyncLifetime |
|||
where TGeneratorModule : IAbpModule |
|||
where TValidatorModule : IAbpModule |
|||
{ |
|||
protected const string UserName = "cross-host"; |
|||
protected const string TenantUserName = "cross-host-tenant"; |
|||
protected const string ResetPasswordPurpose = "ResetPassword"; |
|||
|
|||
protected static readonly Guid TenantId = Guid.Parse("6f6e7b6a-2c1e-4a2f-9d5a-8f2f0f2b7c11"); |
|||
|
|||
protected IAbpApplicationWithInternalServiceProvider GeneratorHost { get; private set; } |
|||
|
|||
protected IAbpApplicationWithInternalServiceProvider ValidatorHost { get; private set; } |
|||
|
|||
private IDisposable _database; |
|||
|
|||
/// <summary>
|
|||
/// Creates the database both hosts share and points
|
|||
/// <see cref="CrossHostTokenProviderTestModuleBase.ConnectionString"/> at it, before the hosts are built.
|
|||
/// </summary>
|
|||
protected abstract IDisposable CreateSharedDatabase(); |
|||
|
|||
/// <summary>
|
|||
/// The module only the validating host loads. Asserted on both sides so the suite cannot quietly
|
|||
/// degrade into two identical hosts, which is not the topology under test.
|
|||
/// </summary>
|
|||
protected abstract Type ValidatorOnlyModuleType { get; } |
|||
|
|||
public virtual async Task InitializeAsync() |
|||
{ |
|||
_database = CreateSharedDatabase(); |
|||
CrossHostTokenProviderTestModuleBase.KeyRepository = new CrossHostInMemoryXmlRepository(); |
|||
|
|||
GeneratorHost = await CreateHostAsync<TGeneratorModule>(); |
|||
ValidatorHost = await CreateHostAsync<TValidatorModule>(); |
|||
} |
|||
|
|||
public virtual async Task DisposeAsync() |
|||
{ |
|||
if (ValidatorHost != null) |
|||
{ |
|||
await ValidatorHost.ShutdownAsync(); |
|||
ValidatorHost.Dispose(); |
|||
} |
|||
|
|||
if (GeneratorHost != null) |
|||
{ |
|||
await GeneratorHost.ShutdownAsync(); |
|||
GeneratorHost.Dispose(); |
|||
} |
|||
|
|||
_database?.Dispose(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void The_Two_Hosts_Should_Be_Independent_Applications() |
|||
{ |
|||
GeneratorHost.ServiceProvider.ShouldNotBeSameAs(ValidatorHost.ServiceProvider); |
|||
|
|||
GeneratorHost.Services.GetSingletonInstance<IModuleContainer>().Modules |
|||
.ShouldNotContain(m => m.Type == ValidatorOnlyModuleType); |
|||
ValidatorHost.Services.GetSingletonInstance<IModuleContainer>().Modules |
|||
.ShouldContain(m => m.Type == ValidatorOnlyModuleType); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Both_Hosts_Should_Resolve_The_Same_Token_Providers() |
|||
{ |
|||
var generatorTokens = GetTokenOptions(GeneratorHost); |
|||
var validatorTokens = GetTokenOptions(ValidatorHost); |
|||
|
|||
generatorTokens.PasswordResetTokenProvider.ShouldBe(validatorTokens.PasswordResetTokenProvider); |
|||
generatorTokens.EmailConfirmationTokenProvider.ShouldBe(validatorTokens.EmailConfirmationTokenProvider); |
|||
generatorTokens.ChangeEmailTokenProvider.ShouldBe(validatorTokens.ChangeEmailTokenProvider); |
|||
|
|||
// Both directions: iterating one map alone would pass while the other side has extra keys.
|
|||
generatorTokens.ProviderMap.Keys.OrderBy(x => x).ShouldBe(validatorTokens.ProviderMap.Keys.OrderBy(x => x)); |
|||
|
|||
foreach (var (name, descriptor) in validatorTokens.ProviderMap) |
|||
{ |
|||
generatorTokens.ProviderMap[name].ProviderType.ShouldBe(descriptor.ProviderType); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task A_Token_Generated_On_The_Other_Host_Should_Verify() |
|||
{ |
|||
var userId = await CreateUserAsync(); |
|||
|
|||
var token = await WithUowAsync(GeneratorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
return await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId)); |
|||
}); |
|||
|
|||
var verified = await WithUowAsync(ValidatorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
var identityOptions = sp.GetRequiredService<IOptions<IdentityOptions>>().Value; |
|||
return await userManager.VerifyUserTokenAsync( |
|||
await userManager.GetByIdAsync(userId), |
|||
identityOptions.Tokens.PasswordResetTokenProvider, |
|||
ResetPasswordPurpose, |
|||
token); |
|||
}); |
|||
|
|||
verified.ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task A_Token_Generated_On_This_Host_Should_Verify_On_The_Other_One() |
|||
{ |
|||
var userId = await CreateUserAsync(); |
|||
|
|||
var token = await WithUowAsync(ValidatorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
return await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId)); |
|||
}); |
|||
|
|||
var verified = await WithUowAsync(GeneratorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
var identityOptions = sp.GetRequiredService<IOptions<IdentityOptions>>().Value; |
|||
return await userManager.VerifyUserTokenAsync( |
|||
await userManager.GetByIdAsync(userId), |
|||
identityOptions.Tokens.PasswordResetTokenProvider, |
|||
ResetPasswordPurpose, |
|||
token); |
|||
}); |
|||
|
|||
verified.ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task A_Password_Reset_Should_Complete_Across_The_Two_Hosts() |
|||
{ |
|||
var userId = await CreateUserAsync(); |
|||
|
|||
var token = await WithUowAsync(GeneratorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
return await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId)); |
|||
}); |
|||
|
|||
var result = await WithUowAsync(ValidatorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
return await userManager.ResetPasswordAsync(await userManager.GetByIdAsync(userId), token, "1q2w3E*NEW"); |
|||
}); |
|||
|
|||
result.Succeeded.ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task An_Email_Confirmation_Token_Should_Complete_Across_The_Two_Hosts() |
|||
{ |
|||
var userId = await CreateUserAsync(); |
|||
|
|||
var token = await WithUowAsync(GeneratorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
return await userManager.GenerateEmailConfirmationTokenAsync(await userManager.GetByIdAsync(userId)); |
|||
}); |
|||
|
|||
var result = await WithUowAsync(ValidatorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
return await userManager.ConfirmEmailAsync(await userManager.GetByIdAsync(userId), token); |
|||
}); |
|||
|
|||
result.Succeeded.ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task A_Change_Email_Token_Should_Complete_Across_The_Two_Hosts() |
|||
{ |
|||
const string newEmail = "changed@abp.io"; |
|||
var userId = await CreateUserAsync(); |
|||
|
|||
var token = await WithUowAsync(GeneratorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
return await userManager.GenerateChangeEmailTokenAsync(await userManager.GetByIdAsync(userId), newEmail); |
|||
}); |
|||
|
|||
var result = await WithUowAsync(ValidatorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
return await userManager.ChangeEmailAsync(await userManager.GetByIdAsync(userId), newEmail, token); |
|||
}); |
|||
|
|||
result.Succeeded.ShouldBeTrue(); |
|||
} |
|||
|
|||
public static TheoryData<string, string> OtherSingleActiveProviders => new() |
|||
{ |
|||
{ TokenOptions.DefaultProvider, "TestPurpose" }, |
|||
{ LinkUserTokenProviderConsts.LinkUserTokenProviderName, LinkUserTokenProviderConsts.LinkUserTokenPurpose }, |
|||
}; |
|||
|
|||
public static TheoryData<string> TwoFactorProviders => new() |
|||
{ |
|||
TokenOptions.DefaultEmailProvider, |
|||
TokenOptions.DefaultPhoneProvider, |
|||
}; |
|||
|
|||
[Theory] |
|||
[MemberData(nameof(OtherSingleActiveProviders))] |
|||
public async Task A_Token_Of_The_Other_Single_Active_Providers_Should_Verify_Across_The_Two_Hosts( |
|||
string providerKey, |
|||
string purpose) |
|||
{ |
|||
// The password reset flows above go through IdentityOptions.Tokens, which leaves the keys that
|
|||
// are addressed directly. They are registered by the same call and break the same way.
|
|||
var userId = await CreateUserAsync(); |
|||
|
|||
var token = await WithUowAsync(GeneratorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
return await userManager.GenerateUserTokenAsync(await userManager.GetByIdAsync(userId), providerKey, purpose); |
|||
}); |
|||
|
|||
var verified = await WithUowAsync(ValidatorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
return await userManager.VerifyUserTokenAsync(await userManager.GetByIdAsync(userId), providerKey, purpose, token); |
|||
}); |
|||
|
|||
verified.ShouldBeTrue(); |
|||
} |
|||
|
|||
[Theory] |
|||
[MemberData(nameof(TwoFactorProviders))] |
|||
public async Task A_Two_Factor_Code_Should_Verify_Across_The_Two_Hosts(string providerKey) |
|||
{ |
|||
// These keep the code in the user token table too, protected under a purpose built from the
|
|||
// provider name, so they need both hosts to agree just as much as the DataProtector ones.
|
|||
var userId = await CreateUserAsync(); |
|||
|
|||
var code = await WithUowAsync(GeneratorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
return await userManager.GenerateTwoFactorTokenAsync(await userManager.GetByIdAsync(userId), providerKey); |
|||
}); |
|||
|
|||
var verified = await WithUowAsync(ValidatorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
return await userManager.VerifyTwoFactorTokenAsync(await userManager.GetByIdAsync(userId), providerKey, code); |
|||
}); |
|||
|
|||
verified.ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task A_Token_Generated_For_A_Tenant_User_Should_Validate_On_The_Other_Host() |
|||
{ |
|||
var userId = await CreateUserAsync(TenantId); |
|||
|
|||
var token = await WithUowAsync(GeneratorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
return await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId)); |
|||
}, TenantId); |
|||
|
|||
var result = await WithUowAsync(ValidatorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
return await userManager.ResetPasswordAsync(await userManager.GetByIdAsync(userId), token, "1q2w3E*NEW"); |
|||
}, TenantId); |
|||
|
|||
result.Succeeded.ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task The_Stored_Hash_Of_A_Tenant_Users_Token_Should_Carry_The_Tenant_Id() |
|||
{ |
|||
// The hash is stored with the user, which is tenant scoped. A host side entry would be
|
|||
// invisible to the tenant and the token would be rejected on the validating side.
|
|||
var userId = await CreateUserAsync(TenantId); |
|||
|
|||
await WithUowAsync(GeneratorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
return await userManager.GeneratePasswordResetTokenAsync(await userManager.GetByIdAsync(userId)); |
|||
}, TenantId); |
|||
|
|||
var storedTenantId = await WithUowAsync(ValidatorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
var user = await userManager.GetByIdAsync(userId); |
|||
await sp.GetRequiredService<IIdentityUserRepository>().EnsureCollectionLoadedAsync(user, u => u.Tokens); |
|||
|
|||
return user.FindToken( |
|||
AbpSingleActiveTokenProvider.InternalLoginProvider, |
|||
AbpPasswordResetTokenProvider.ProviderName + ":" + ResetPasswordPurpose).TenantId; |
|||
}, TenantId); |
|||
|
|||
storedTenantId.ShouldBe(TenantId); |
|||
} |
|||
|
|||
protected async Task<Guid> CreateUserAsync(Guid? tenantId = null) |
|||
{ |
|||
var userName = tenantId == null ? UserName : TenantUserName; |
|||
|
|||
return await WithUowAsync(GeneratorHost, async sp => |
|||
{ |
|||
var userManager = sp.GetRequiredService<IdentityUserManager>(); |
|||
var existing = await userManager.FindByNameAsync(userName); |
|||
if (existing != null) |
|||
{ |
|||
return existing.Id; |
|||
} |
|||
|
|||
var user = new IdentityUser(Guid.NewGuid(), userName, userName + "@abp.io", tenantId); |
|||
(await userManager.CreateAsync(user, "1q2w3E*")).CheckErrors(); |
|||
return user.Id; |
|||
}, tenantId); |
|||
} |
|||
|
|||
protected static TokenOptions GetTokenOptions(IAbpApplicationWithInternalServiceProvider host) |
|||
{ |
|||
return host.ServiceProvider.GetRequiredService<IOptions<IdentityOptions>>().Value.Tokens; |
|||
} |
|||
|
|||
protected static async Task<IAbpApplicationWithInternalServiceProvider> CreateHostAsync<TModule>() |
|||
where TModule : IAbpModule |
|||
{ |
|||
var host = await AbpApplicationFactory.CreateAsync<TModule>(options => options.UseAutofac()); |
|||
await host.InitializeAsync(); |
|||
return host; |
|||
} |
|||
|
|||
protected static async Task<TResult> WithUowAsync<TResult>( |
|||
IAbpApplicationWithInternalServiceProvider host, |
|||
Func<IServiceProvider, Task<TResult>> action, |
|||
Guid? tenantId = null) |
|||
{ |
|||
using var scope = host.ServiceProvider.CreateScope(); |
|||
using var tenantScope = scope.ServiceProvider.GetRequiredService<ICurrentTenant>().Change(tenantId); |
|||
var unitOfWorkManager = scope.ServiceProvider.GetRequiredService<IUnitOfWorkManager>(); |
|||
using var unitOfWork = unitOfWorkManager.Begin(new AbpUnitOfWorkOptions { IsTransactional = false }, requiresNew: true); |
|||
var result = await action(scope.ServiceProvider); |
|||
await unitOfWork.CompleteAsync(); |
|||
return result; |
|||
} |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// The part of the host configuration that does not depend on the persistence provider. Each provider
|
|||
/// derives from this and adds its own database configuration.
|
|||
/// </summary>
|
|||
public abstract class CrossHostTokenProviderTestModuleBase : AbpModule |
|||
{ |
|||
public static string ConnectionString; |
|||
public static IXmlRepository KeyRepository; |
|||
|
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
Configure<AbpDbConnectionOptions>(options => options.ConnectionStrings.Default = ConnectionString); |
|||
|
|||
// Both hosts share one key ring, the way a distributed deployment does.
|
|||
context.Services.AddDataProtection().SetApplicationName("CrossHostTokenProviderTests"); |
|||
context.Services.Configure<KeyManagementOptions>(options => options.XmlRepository = KeyRepository); |
|||
} |
|||
} |
|||
|
|||
public class CrossHostInMemoryXmlRepository : IXmlRepository |
|||
{ |
|||
private readonly List<XElement> _elements = new List<XElement>(); |
|||
|
|||
public IReadOnlyCollection<XElement> GetAllElements() |
|||
{ |
|||
lock (_elements) |
|||
{ |
|||
return _elements.Select(x => new XElement(x)).ToList(); |
|||
} |
|||
} |
|||
|
|||
public void StoreElement(XElement element, string friendlyName) |
|||
{ |
|||
lock (_elements) |
|||
{ |
|||
_elements.Add(new XElement(element)); |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue