diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpLinkUserTokenProviderOptions.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpLinkUserTokenProviderOptions.cs new file mode 100644 index 0000000000..ec06cf0156 --- /dev/null +++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpLinkUserTokenProviderOptions.cs @@ -0,0 +1,13 @@ +using System; +using Microsoft.AspNetCore.Identity; + +namespace Volo.Abp.Identity.AspNetCore; + +public class AbpLinkUserTokenProviderOptions : DataProtectionTokenProviderOptions +{ + public AbpLinkUserTokenProviderOptions() + { + Name = LinkUserTokenProviderConsts.LinkUserTokenProviderName; + TokenLifespan = TimeSpan.FromMinutes(10); + } +} diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions.cs index 5b721241fd..59d8bd4690 100644 --- a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions.cs +++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/IdentityUserManagerSingleActiveTokenExtensions.cs @@ -40,4 +40,23 @@ public static class IdentityUserManagerSingleActiveTokenExtensions var name = manager.Options.Tokens.ChangeEmailTokenProvider + ":" + UserManager.GetChangeEmailTokenPurpose(newEmail); return manager.RemoveAuthenticationTokenAsync(user, AbpSingleActiveTokenProvider.InternalLoginProvider, name); } + + /// + /// Removes the stored link-user token hash for , + /// immediately invalidating any previously issued link-user token. + /// + public static Task RemoveLinkUserTokenAsync(this IdentityUserManager manager, IdentityUser user) + { + return RemoveLinkUserTokenAsync(manager, user, LinkUserTokenProviderConsts.LinkUserTokenPurpose); + } + + /// + /// Removes the stored link-user token hash for and the given , + /// immediately invalidating any previously issued link-user token for that purpose. + /// + public static Task RemoveLinkUserTokenAsync(this IdentityUserManager manager, IdentityUser user, string purpose) + { + var name = LinkUserTokenProviderConsts.LinkUserTokenProviderName + ":" + purpose; + return manager.RemoveAuthenticationTokenAsync(user, AbpSingleActiveTokenProvider.InternalLoginProvider, name); + } } diff --git a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/LinkUserTokenProvider.cs b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/LinkUserTokenProvider.cs index b2fe7c772c..47607abe7e 100644 --- a/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/LinkUserTokenProvider.cs +++ b/modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/LinkUserTokenProvider.cs @@ -1,18 +1,25 @@ -using Microsoft.AspNetCore.DataProtection; +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; -public class LinkUserTokenProvider : DataProtectorTokenProvider +/// +/// Link-user token provider that enforces, per purpose, only the most recently issued +/// token to be valid, with a configurable expiration period. +/// +public class LinkUserTokenProvider : AbpSingleActiveTokenProvider { public LinkUserTokenProvider( IDataProtectionProvider dataProtectionProvider, - IOptions options, - ILogger> logger) - : base(dataProtectionProvider, options, logger) + IOptions options, + ILogger> logger, + IIdentityUserRepository userRepository, + ICancellationTokenProvider cancellationTokenProvider) + : base(dataProtectionProvider, options, logger, userRepository, cancellationTokenProvider) { - } } diff --git a/modules/identity/src/Volo.Abp.Identity.Domain.Shared/Volo/Abp/Identity/LinkUserTokenProviderConsts.cs b/modules/identity/src/Volo.Abp.Identity.Domain.Shared/Volo/Abp/Identity/LinkUserTokenProviderConsts.cs index 1432210edb..ad80affc26 100644 --- a/modules/identity/src/Volo.Abp.Identity.Domain.Shared/Volo/Abp/Identity/LinkUserTokenProviderConsts.cs +++ b/modules/identity/src/Volo.Abp.Identity.Domain.Shared/Volo/Abp/Identity/LinkUserTokenProviderConsts.cs @@ -7,4 +7,8 @@ public static class LinkUserTokenProviderConsts public static string LinkUserTokenPurpose { get; set; } = "AbpLinkUser"; public static string LinkUserLoginTokenPurpose { get; set; } = "AbpLinkUserLogin"; + + public static string LinkUserConsentLoginProvider { get; set; } = "[AbpLinkUserConsent]"; + + public static string LinkUserConsentTokenName { get; set; } = "Consent"; } diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityLinkUserManager.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityLinkUserManager.cs index 19886bdd3a..25a5f299ff 100644 --- a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityLinkUserManager.cs +++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityLinkUserManager.cs @@ -3,8 +3,10 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; using Volo.Abp.Domain.Services; using Volo.Abp.MultiTenancy; +using Volo.Abp.Uow; namespace Volo.Abp.Identity; @@ -160,4 +162,56 @@ public class IdentityLinkUserManager : DomainService token); } } + + [UnitOfWork] + public virtual async Task SetLinkConsentAsync(IdentityLinkUserInfo sourceLinkUser, string consent, CancellationToken cancellationToken = default) + { + using (CurrentTenant.Change(sourceLinkUser.TenantId)) + { + var user = await UserManager.FindByIdAsync(sourceLinkUser.UserId.ToString()); + if (user == null) + { + return; + } + (await UserManager.SetAuthenticationTokenAsync( + user, + LinkUserTokenProviderConsts.LinkUserConsentLoginProvider, + LinkUserTokenProviderConsts.LinkUserConsentTokenName, + consent)).CheckErrors(); + } + } + + [UnitOfWork] + public virtual async Task GetLinkConsentAsync(IdentityLinkUserInfo sourceLinkUser, CancellationToken cancellationToken = default) + { + using (CurrentTenant.Change(sourceLinkUser.TenantId)) + { + var user = await UserManager.FindByIdAsync(sourceLinkUser.UserId.ToString()); + if (user == null) + { + return null; + } + return await UserManager.GetAuthenticationTokenAsync( + user, + LinkUserTokenProviderConsts.LinkUserConsentLoginProvider, + LinkUserTokenProviderConsts.LinkUserConsentTokenName); + } + } + + [UnitOfWork] + public virtual async Task RemoveLinkConsentAsync(IdentityLinkUserInfo sourceLinkUser, CancellationToken cancellationToken = default) + { + using (CurrentTenant.Change(sourceLinkUser.TenantId)) + { + var user = await UserManager.FindByIdAsync(sourceLinkUser.UserId.ToString()); + if (user == null) + { + return; + } + (await UserManager.RemoveAuthenticationTokenAsync( + user, + LinkUserTokenProviderConsts.LinkUserConsentLoginProvider, + LinkUserTokenProviderConsts.LinkUserConsentTokenName)).CheckErrors(); + } + } } diff --git a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/LinkUserTokenProvider_Tests.cs b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/LinkUserTokenProvider_Tests.cs index 75874feb33..7dfca71f92 100644 --- a/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/LinkUserTokenProvider_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/LinkUserTokenProvider_Tests.cs @@ -1,28 +1,40 @@ -using System.Threading.Tasks; +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 LinkUserTokenProvider_Tests : AbpIdentityAspNetCoreTestBase +public class LinkUserTokenProvider_Tests : AbpSingleActiveTokenProviderTestBase { - protected IIdentityUserRepository UserRepository { get; } - protected IIdentityLinkUserRepository IdentityLinkUserRepository { get; } protected IdentityLinkUserManager IdentityLinkUserManager { get; } - protected IdentityTestData TestData { get; } public LinkUserTokenProvider_Tests() { - UserRepository = GetRequiredService(); - IdentityLinkUserRepository = GetRequiredService(); IdentityLinkUserManager = GetRequiredService(); - TestData = GetRequiredService(); } + protected override Task GenerateTokenAsync(IdentityUser user) + => IdentityLinkUserManager.GenerateLinkTokenAsync( + new IdentityLinkUserInfo(user.Id, user.TenantId), + LinkUserTokenProviderConsts.LinkUserTokenPurpose); + + protected override Task VerifyTokenAsync(IdentityUser user, string token) + => IdentityLinkUserManager.VerifyLinkTokenAsync( + new IdentityLinkUserInfo(user.Id, user.TenantId), + token, + LinkUserTokenProviderConsts.LinkUserTokenPurpose); + + protected override string GetProviderName() + => LinkUserTokenProviderConsts.LinkUserTokenProviderName; + + protected override string GetPurpose() + => LinkUserTokenProviderConsts.LinkUserTokenPurpose; + [Fact] - public void LinkUserTokenProvider_Should_Be_Register() + public void LinkUserTokenProvider_Should_Be_Registered() { var identityOptions = GetRequiredService>().Value; @@ -35,11 +47,79 @@ public class LinkUserTokenProvider_Tests : AbpIdentityAspNetCoreTestBase [InlineData("TestTokenPurpose1")] [InlineData("TestTokenPurpose2")] [InlineData("TestTokenPurpose3")] - public virtual async Task GenerateAndVerifyLinkTokenAsync(string tokenPurpose) + public virtual async Task GenerateAndVerifyLinkTokenAsync_With_Custom_Purpose(string tokenPurpose) + { + using (var uow = UnitOfWorkManager.Begin()) + { + var john = await UserRepository.GetAsync(TestData.UserJohnId); + var token = await IdentityLinkUserManager.GenerateLinkTokenAsync(new IdentityLinkUserInfo(john.Id, john.TenantId), tokenPurpose); + (await IdentityLinkUserManager.VerifyLinkTokenAsync(new IdentityLinkUserInfo(john.Id, john.TenantId), token, tokenPurpose)).ShouldBeTrue(); + (await IdentityLinkUserManager.VerifyLinkTokenAsync(new IdentityLinkUserInfo(john.Id, john.TenantId), "123123", tokenPurpose)).ShouldBeFalse(); + await uow.CompleteAsync(); + } + } + + [Fact] + public async Task RemoveLinkUserTokenAsync_Should_Invalidate_Stored_Hash() + { + using (var uow = UnitOfWorkManager.Begin()) + { + var john = await UserRepository.GetAsync(TestData.UserJohnId); + var token = await GenerateTokenAsync(john); + + john = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.RemoveLinkUserTokenAsync(john)).Succeeded.ShouldBeTrue(); + + john = await UserRepository.GetAsync(TestData.UserJohnId); + (await VerifyTokenAsync(john, token)).ShouldBeFalse(); + + await uow.CompleteAsync(); + } + } + + [Fact] + public async Task RemoveLinkUserTokenAsync_With_Custom_Purpose_Should_Invalidate_Only_That_Purpose() { - var john = await UserRepository.GetAsync(TestData.UserJohnId); - var token = await IdentityLinkUserManager.GenerateLinkTokenAsync(new IdentityLinkUserInfo(john.Id, john.TenantId), tokenPurpose); - (await IdentityLinkUserManager.VerifyLinkTokenAsync(new IdentityLinkUserInfo(john.Id, john.TenantId), token, tokenPurpose)).ShouldBeTrue(); - (await IdentityLinkUserManager.VerifyLinkTokenAsync(new IdentityLinkUserInfo(john.Id, john.TenantId), "123123", tokenPurpose)).ShouldBeFalse(); + const string customPurpose = "CustomLinkUserPurpose"; + + using (var uow = UnitOfWorkManager.Begin()) + { + var john = await UserRepository.GetAsync(TestData.UserJohnId); + var defaultPurposeToken = await GenerateTokenAsync(john); + var customPurposeToken = await IdentityLinkUserManager.GenerateLinkTokenAsync( + new IdentityLinkUserInfo(john.Id, john.TenantId), + customPurpose); + + john = await UserRepository.GetAsync(TestData.UserJohnId); + (await UserManager.RemoveLinkUserTokenAsync(john, customPurpose)).Succeeded.ShouldBeTrue(); + + john = await UserRepository.GetAsync(TestData.UserJohnId); + (await IdentityLinkUserManager.VerifyLinkTokenAsync(new IdentityLinkUserInfo(john.Id, john.TenantId), customPurposeToken, customPurpose)).ShouldBeFalse(); + (await VerifyTokenAsync(john, defaultPurposeToken)).ShouldBeTrue(); + + await uow.CompleteAsync(); + } + } + + [Fact] + public async Task GenerateLinkTokenAsync_With_Custom_Purpose_Should_Invalidate_Previous_Token_For_Same_Purpose() + { + const string customPurpose = "CustomLinkUserPurpose"; + + using (var uow = UnitOfWorkManager.Begin()) + { + var john = await UserRepository.GetAsync(TestData.UserJohnId); + var firstToken = await IdentityLinkUserManager.GenerateLinkTokenAsync( + new IdentityLinkUserInfo(john.Id, john.TenantId), + customPurpose); + var secondToken = await IdentityLinkUserManager.GenerateLinkTokenAsync( + new IdentityLinkUserInfo(john.Id, john.TenantId), + customPurpose); + + (await IdentityLinkUserManager.VerifyLinkTokenAsync(new IdentityLinkUserInfo(john.Id, john.TenantId), firstToken, customPurpose)).ShouldBeFalse(); + (await IdentityLinkUserManager.VerifyLinkTokenAsync(new IdentityLinkUserInfo(john.Id, john.TenantId), secondToken, customPurpose)).ShouldBeTrue(); + + await uow.CompleteAsync(); + } } } diff --git a/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityLinkUserManager_Tests.cs b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityLinkUserManager_Tests.cs index e13fefb1c2..f151cc5010 100644 --- a/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityLinkUserManager_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityLinkUserManager_Tests.cs @@ -188,4 +188,99 @@ public class IdentityLinkUserManager_Tests : AbpIdentityDomainTestBase (await IdentityLinkUserManager.IsLinkedAsync(new IdentityLinkUserInfo(john.Id, john.TenantId), new IdentityLinkUserInfo(neo.Id, neo.TenantId))).ShouldBeFalse(); } + + [Fact] + public virtual async Task SetLinkConsentAsync() + { + await UsingUowAsync(async () => + { + var john = await UserRepository.GetAsync(TestData.UserJohnId); + var source = new IdentityLinkUserInfo(john.Id, john.TenantId); + + await IdentityLinkUserManager.SetLinkConsentAsync(source, "consent-payload"); + + (await IdentityLinkUserManager.GetLinkConsentAsync(source)).ShouldBe("consent-payload"); + }); + } + + [Fact] + public virtual async Task SetLinkConsentAsync_Should_Overwrite_Previous_Value() + { + await UsingUowAsync(async () => + { + var john = await UserRepository.GetAsync(TestData.UserJohnId); + var source = new IdentityLinkUserInfo(john.Id, john.TenantId); + + await IdentityLinkUserManager.SetLinkConsentAsync(source, "first"); + await IdentityLinkUserManager.SetLinkConsentAsync(source, "second"); + + (await IdentityLinkUserManager.GetLinkConsentAsync(source)).ShouldBe("second"); + }); + } + + [Fact] + public virtual async Task GetLinkConsentAsync_Should_Return_Null_When_No_Consent_Written() + { + var neo = await UserRepository.GetAsync(TestData.UserNeoId); + + var stored = await IdentityLinkUserManager.GetLinkConsentAsync(new IdentityLinkUserInfo(neo.Id, neo.TenantId)); + + stored.ShouldBeNull(); + } + + [Fact] + public virtual async Task GetLinkConsentAsync_Should_Return_Null_When_User_Not_Found() + { + var stored = await IdentityLinkUserManager.GetLinkConsentAsync(new IdentityLinkUserInfo(Guid.NewGuid(), null)); + + stored.ShouldBeNull(); + } + + [Fact] + public virtual async Task RemoveLinkConsentAsync() + { + await UsingUowAsync(async () => + { + var john = await UserRepository.GetAsync(TestData.UserJohnId); + var source = new IdentityLinkUserInfo(john.Id, john.TenantId); + + await IdentityLinkUserManager.SetLinkConsentAsync(source, "payload"); + await IdentityLinkUserManager.RemoveLinkConsentAsync(source); + + (await IdentityLinkUserManager.GetLinkConsentAsync(source)).ShouldBeNull(); + }); + } + + [Fact] + public virtual async Task RemoveLinkConsentAsync_Should_Be_Noop_When_User_Not_Found() + { + await IdentityLinkUserManager.RemoveLinkConsentAsync(new IdentityLinkUserInfo(Guid.NewGuid(), null)); + } + + [Fact] + public virtual async Task SetLinkConsentAsync_Should_Persist_Across_UnitOfWork() + { + var john = await UserRepository.GetAsync(TestData.UserJohnId); + var source = new IdentityLinkUserInfo(john.Id, john.TenantId); + + await UsingUowAsync(async () => + { + await IdentityLinkUserManager.SetLinkConsentAsync(source, "consent-payload"); + }); + + await UsingUowAsync(async () => + { + (await IdentityLinkUserManager.GetLinkConsentAsync(source)).ShouldBe("consent-payload"); + }); + + await UsingUowAsync(async () => + { + await IdentityLinkUserManager.RemoveLinkConsentAsync(source); + }); + + await UsingUowAsync(async () => + { + (await IdentityLinkUserManager.GetLinkConsentAsync(source)).ShouldBeNull(); + }); + } }