From dba3177f883b639b7a88102ab7e8b51e60623b6f Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 19 May 2026 16:51:25 +0800 Subject: [PATCH 1/4] Enhance LinkUserTokenProvider with single-active policy and consent helpers - Make LinkUserTokenProvider derive from AbpSingleActiveTokenProvider - Add AbpLinkUserTokenProviderOptions with 10 min default lifespan - Add RemoveLinkUserTokenAsync extension on IdentityUserManager - Add SetLinkConsentAsync/GetLinkConsentAsync/RemoveLinkConsentAsync on IdentityLinkUserManager backed by user.Tokens slot [AbpLinkUserConsent]/Consent --- .../AbpLinkUserTokenProviderOptions.cs | 13 ++++ ...yUserManagerSingleActiveTokenExtensions.cs | 10 +++ .../AspNetCore/LinkUserTokenProvider.cs | 19 ++++-- .../Identity/LinkUserTokenProviderConsts.cs | 4 ++ .../Abp/Identity/IdentityLinkUserManager.cs | 45 ++++++++++++ .../AspNetCore/LinkUserTokenProvider_Tests.cs | 58 +++++++++++++--- .../Identity/IdentityLinkUserManager_Tests.cs | 68 +++++++++++++++++++ 7 files changed, 200 insertions(+), 17 deletions(-) create mode 100644 modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpLinkUserTokenProviderOptions.cs 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..6b6f9769de 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,14 @@ 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) + { + var name = LinkUserTokenProviderConsts.LinkUserTokenProviderName + ":" + LinkUserTokenProviderConsts.LinkUserTokenPurpose; + 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..6cbdeece99 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 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..7e623e2094 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,6 +3,7 @@ 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; @@ -160,4 +161,48 @@ public class IdentityLinkUserManager : DomainService token); } } + + public virtual async Task SetLinkConsentAsync(IdentityLinkUserInfo sourceLinkUser, string consent, CancellationToken cancellationToken = default) + { + using (CurrentTenant.Change(sourceLinkUser.TenantId)) + { + var user = await UserManager.GetByIdAsync(sourceLinkUser.UserId); + user.SetToken( + LinkUserTokenProviderConsts.LinkUserConsentLoginProvider, + LinkUserTokenProviderConsts.LinkUserConsentTokenName, + consent); + (await UserManager.UpdateAsync(user)).CheckErrors(); + } + } + + 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 user.FindToken( + LinkUserTokenProviderConsts.LinkUserConsentLoginProvider, + LinkUserTokenProviderConsts.LinkUserConsentTokenName)?.Value; + } + } + + 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; + } + user.RemoveToken( + LinkUserTokenProviderConsts.LinkUserConsentLoginProvider, + LinkUserTokenProviderConsts.LinkUserConsentTokenName); + (await UserManager.UpdateAsync(user)).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..06a90e5410 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,26 +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() { @@ -35,11 +49,33 @@ 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) { - 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(); + 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(); + } } } 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..22503ed3ef 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,72 @@ 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)); + } } From 3f52b0aecdc9243b190311c88272f58fd201bcb5 Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 19 May 2026 17:17:30 +0800 Subject: [PATCH 2/4] Mark GetLinkConsentAsync nullable return to clarify the contract --- .../Volo/Abp/Identity/IdentityLinkUserManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7e623e2094..08d6204734 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 @@ -175,7 +175,7 @@ public class IdentityLinkUserManager : DomainService } } - public virtual async Task GetLinkConsentAsync(IdentityLinkUserInfo sourceLinkUser, CancellationToken cancellationToken = default) + public virtual async Task GetLinkConsentAsync(IdentityLinkUserInfo sourceLinkUser, CancellationToken cancellationToken = default) { using (CurrentTenant.Change(sourceLinkUser.TenantId)) { From 8429dbaca4cd5d76efbd5ad8bcd4d6a2de364460 Mon Sep 17 00:00:00 2001 From: maliming Date: Wed, 20 May 2026 11:06:39 +0800 Subject: [PATCH 3/4] Address Copilot review on #25450 - Use UserManager.SetAuthenticationTokenAsync/GetAuthenticationTokenAsync/ RemoveAuthenticationTokenAsync so the Tokens collection is loaded via IdentityUserStore.EnsureCollectionLoadedAsync instead of relying on the GetByIdAsync(includeDetails) convention. - Add a RemoveLinkUserTokenAsync(manager, user, purpose) overload to invalidate tokens issued for purposes other than LinkUserTokenPurpose. - Add a cross-UnitOfWork persistence test for SetLink/Get/Remove ConsentAsync. - Drop the unused IdentityLinkUserRepository field from LinkUserTokenProvider_Tests. --- ...yUserManagerSingleActiveTokenExtensions.cs | 11 +++++++- .../Abp/Identity/IdentityLinkUserManager.cs | 17 ++++++------ .../AspNetCore/LinkUserTokenProvider_Tests.cs | 2 -- .../Identity/IdentityLinkUserManager_Tests.cs | 27 +++++++++++++++++++ 4 files changed, 46 insertions(+), 11 deletions(-) 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 6b6f9769de..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 @@ -47,7 +47,16 @@ public static class IdentityUserManagerSingleActiveTokenExtensions /// public static Task RemoveLinkUserTokenAsync(this IdentityUserManager manager, IdentityUser user) { - var name = LinkUserTokenProviderConsts.LinkUserTokenProviderName + ":" + LinkUserTokenProviderConsts.LinkUserTokenPurpose; + 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.Domain/Volo/Abp/Identity/IdentityLinkUserManager.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityLinkUserManager.cs index 08d6204734..f005b52cca 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 @@ -167,11 +167,11 @@ public class IdentityLinkUserManager : DomainService using (CurrentTenant.Change(sourceLinkUser.TenantId)) { var user = await UserManager.GetByIdAsync(sourceLinkUser.UserId); - user.SetToken( + (await UserManager.SetAuthenticationTokenAsync( + user, LinkUserTokenProviderConsts.LinkUserConsentLoginProvider, LinkUserTokenProviderConsts.LinkUserConsentTokenName, - consent); - (await UserManager.UpdateAsync(user)).CheckErrors(); + consent)).CheckErrors(); } } @@ -184,9 +184,10 @@ public class IdentityLinkUserManager : DomainService { return null; } - return user.FindToken( + return await UserManager.GetAuthenticationTokenAsync( + user, LinkUserTokenProviderConsts.LinkUserConsentLoginProvider, - LinkUserTokenProviderConsts.LinkUserConsentTokenName)?.Value; + LinkUserTokenProviderConsts.LinkUserConsentTokenName); } } @@ -199,10 +200,10 @@ public class IdentityLinkUserManager : DomainService { return; } - user.RemoveToken( + (await UserManager.RemoveAuthenticationTokenAsync( + user, LinkUserTokenProviderConsts.LinkUserConsentLoginProvider, - LinkUserTokenProviderConsts.LinkUserConsentTokenName); - (await UserManager.UpdateAsync(user)).CheckErrors(); + 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 06a90e5410..962538a5ae 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 @@ -9,12 +9,10 @@ namespace Volo.Abp.Identity.AspNetCore; public class LinkUserTokenProvider_Tests : AbpSingleActiveTokenProviderTestBase { - protected IIdentityLinkUserRepository IdentityLinkUserRepository { get; } protected IdentityLinkUserManager IdentityLinkUserManager { get; } public LinkUserTokenProvider_Tests() { - IdentityLinkUserRepository = GetRequiredService(); IdentityLinkUserManager = GetRequiredService(); } 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 22503ed3ef..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 @@ -256,4 +256,31 @@ public class IdentityLinkUserManager_Tests : AbpIdentityDomainTestBase { 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(); + }); + } } From fdfb8e5db94f6c105aaf39819c6766993b0668ec Mon Sep 17 00:00:00 2001 From: maliming Date: Wed, 20 May 2026 12:01:21 +0800 Subject: [PATCH 4/4] Address GitHub Copilot and Codex review on #25450 - SetLinkConsentAsync uses FindByIdAsync + null no-op, matching the missing-user behaviour of Get/RemoveLinkConsentAsync. - Decorate Set/Get/RemoveLinkConsentAsync with [UnitOfWork] so the underlying IdentityUserStore.GetTokenAsync can EnsureCollectionLoaded the user.Tokens collection (fixes the CI failure on GetLinkConsentAsync_Should_Return_Null_When_No_Consent_Written). - Clarify LinkUserTokenProvider XML doc to note that the single-active policy is enforced per purpose (matches AbpSingleActiveTokenProvider). - Rename LinkUserTokenProvider_Should_Be_Register to *_Registered for consistency with sibling token-provider tests. - Add tests covering: (a) RemoveLinkUserTokenAsync(purpose) only invalidates the requested purpose, and (b) same-purpose GenerateLink TokenAsync invalidates the previously issued token. --- .../AspNetCore/LinkUserTokenProvider.cs | 2 +- .../Abp/Identity/IdentityLinkUserManager.cs | 10 +++- .../AspNetCore/LinkUserTokenProvider_Tests.cs | 48 ++++++++++++++++++- 3 files changed, 57 insertions(+), 3 deletions(-) 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 6cbdeece99..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 @@ -8,7 +8,7 @@ using Volo.Abp.Threading; namespace Volo.Abp.Identity.AspNetCore; /// -/// Link-user token provider that enforces only the most recently issued +/// 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 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 f005b52cca..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 @@ -6,6 +6,7 @@ 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; @@ -162,11 +163,16 @@ public class IdentityLinkUserManager : DomainService } } + [UnitOfWork] public virtual async Task SetLinkConsentAsync(IdentityLinkUserInfo sourceLinkUser, string consent, CancellationToken cancellationToken = default) { using (CurrentTenant.Change(sourceLinkUser.TenantId)) { - var user = await UserManager.GetByIdAsync(sourceLinkUser.UserId); + var user = await UserManager.FindByIdAsync(sourceLinkUser.UserId.ToString()); + if (user == null) + { + return; + } (await UserManager.SetAuthenticationTokenAsync( user, LinkUserTokenProviderConsts.LinkUserConsentLoginProvider, @@ -175,6 +181,7 @@ public class IdentityLinkUserManager : DomainService } } + [UnitOfWork] public virtual async Task GetLinkConsentAsync(IdentityLinkUserInfo sourceLinkUser, CancellationToken cancellationToken = default) { using (CurrentTenant.Change(sourceLinkUser.TenantId)) @@ -191,6 +198,7 @@ public class IdentityLinkUserManager : DomainService } } + [UnitOfWork] public virtual async Task RemoveLinkConsentAsync(IdentityLinkUserInfo sourceLinkUser, CancellationToken cancellationToken = default) { using (CurrentTenant.Change(sourceLinkUser.TenantId)) 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 962538a5ae..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 @@ -34,7 +34,7 @@ public class LinkUserTokenProvider_Tests : AbpSingleActiveTokenProviderTestBase => LinkUserTokenProviderConsts.LinkUserTokenPurpose; [Fact] - public void LinkUserTokenProvider_Should_Be_Register() + public void LinkUserTokenProvider_Should_Be_Registered() { var identityOptions = GetRequiredService>().Value; @@ -76,4 +76,50 @@ public class LinkUserTokenProvider_Tests : AbpSingleActiveTokenProviderTestBase await uow.CompleteAsync(); } } + + [Fact] + public async Task RemoveLinkUserTokenAsync_With_Custom_Purpose_Should_Invalidate_Only_That_Purpose() + { + 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(); + } + } }