From cb7667557b654fc6d2d35a6a92b11f74c266c730 Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 31 Jul 2026 17:17:02 +0800 Subject: [PATCH] Update user's last sign-in time in a separate unit of work --- .../Volo/Abp/Identity/IdentityUserManager.cs | 60 ++++++++++++++++++- .../IdentityUserRepository_Tests.cs | 32 +++++++++- .../Identity/IdentityUserRepository_Tests.cs | 24 ++++++++ .../AbpResourceOwnerPasswordValidator.cs | 3 +- .../Controllers/TokenController.Password.cs | 8 ++- 5 files changed, 121 insertions(+), 6 deletions(-) diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs index 73f997d982..ba11999b09 100644 --- a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs +++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs @@ -36,6 +36,7 @@ public class IdentityUserManager : UserManager, IDomainService protected IOptions MultiTenancyOptions { get; } protected ICurrentTenant CurrentTenant { get; } protected IDataFilter DataFilter { get; } + protected IUnitOfWorkManager UnitOfWorkManager { get; } public IdentityUserManager( IdentityUserStore store, @@ -57,7 +58,8 @@ public class IdentityUserManager : UserManager, IDomainService IDistributedCache dynamicClaimCache, IOptions multiTenancyOptions, ICurrentTenant currentTenant, - IDataFilter dataFilter) + IDataFilter dataFilter, + IUnitOfWorkManager unitOfWorkManager) : base( store, optionsAccessor, @@ -79,6 +81,7 @@ public class IdentityUserManager : UserManager, IDomainService MultiTenancyOptions = multiTenancyOptions; CurrentTenant = currentTenant; DataFilter = dataFilter; + UnitOfWorkManager = unitOfWorkManager; CancellationTokenProvider = cancellationTokenProvider; } @@ -167,6 +170,61 @@ public class IdentityUserManager : UserManager, IDomainService return user; } + public virtual async Task UpdateLastSignInTimeAsync(Guid id, DateTimeOffset? lastSignInTime = null) + { + var time = lastSignInTime ?? DateTimeOffset.UtcNow; + + var currentUow = UnitOfWorkManager.Current; + if (currentUow != null) + { + // The current unit of work may hold uncommitted changes of the same user (e.g. a new + // registration or a lockout counter reset), so update the time after it completes. + var tenantId = CurrentTenant.Id; + currentUow.OnCompleted(async () => + { + using (CurrentTenant.Change(tenantId)) + { + await TryUpdateLastSignInTimeAsync(id, time); + } + }); + + return; + } + + await TryUpdateLastSignInTimeAsync(id, time); + } + + protected virtual async Task TryUpdateLastSignInTimeAsync(Guid id, DateTimeOffset lastSignInTime) + { + try + { + // Update the last sign-in time in a separate unit of work with a freshly + // loaded user, so a concurrency conflict can't fail the current operation. + using (var uow = UnitOfWorkManager.Begin(requiresNew: true)) + { + var user = await Store.FindByIdAsync(id.ToString(), CancellationToken); + if (user == null || user.LastSignInTime >= lastSignInTime) + { + return; + } + + user.SetLastSignInTime(lastSignInTime); + + var result = await UpdateAsync(user); + if (result.Succeeded) + { + await uow.CompleteAsync(); + } + } + } + catch (Exception e) + { + // This is a best-effort update. The user may be updated concurrently + // by another login or any other operation. Ignore the failure. + Logger.LogException(e); + } + } + public virtual async Task SetRolesAsync([NotNull] IdentityUser user, [NotNull] IEnumerable roleNames) { diff --git a/modules/identity/test/Volo.Abp.Identity.EntityFrameworkCore.Tests/Volo/Abp/Identity/EntityFrameworkCore/IdentityUserRepository_Tests.cs b/modules/identity/test/Volo.Abp.Identity.EntityFrameworkCore.Tests/Volo/Abp/Identity/EntityFrameworkCore/IdentityUserRepository_Tests.cs index 651e799578..c1bad84760 100644 --- a/modules/identity/test/Volo.Abp.Identity.EntityFrameworkCore.Tests/Volo/Abp/Identity/EntityFrameworkCore/IdentityUserRepository_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.EntityFrameworkCore.Tests/Volo/Abp/Identity/EntityFrameworkCore/IdentityUserRepository_Tests.cs @@ -1,6 +1,36 @@ -namespace Volo.Abp.Identity.EntityFrameworkCore; +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Uow; +using Xunit; + +namespace Volo.Abp.Identity.EntityFrameworkCore; public class IdentityUserRepository_Tests : IdentityUserRepository_Tests { + [Fact] + public async Task UpdateLastSignInTimeAsync_Should_Be_Deferred_In_A_Transactional_UnitOfWork() + { + var userManager = ServiceProvider.GetRequiredService(); + var unitOfWorkManager = ServiceProvider.GetRequiredService(); + + var user = new IdentityUser(Guid.NewGuid(), "bob.lee", "bob.lee@abp.io"); + + using (var uow = unitOfWorkManager.Begin(new AbpUnitOfWorkOptions + { + IsTransactional = true + }, requiresNew: true)) + { + (await userManager.CreateAsync(user)).CheckErrors(); + await userManager.UpdateLastSignInTimeAsync(user.Id); + + await uow.CompleteAsync(); + } + var createdUser = await UserRepository.FindAsync(user.Id); + createdUser.ShouldNotBeNull(); + createdUser.LastSignInTime.ShouldNotBeNull(); + } } diff --git a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserRepository_Tests.cs b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserRepository_Tests.cs index ebcc594515..ce7b002c59 100644 --- a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserRepository_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserRepository_Tests.cs @@ -302,6 +302,30 @@ public abstract class IdentityUserRepository_Tests : AbpIdentity ou112Users.ShouldContain(x => x.UserName == "neo"); } + [Fact] + public async Task UpdateLastSignInTimeAsync() + { + var userManager = ServiceProvider.GetRequiredService(); + + var john = await UserRepository.FindByNormalizedUserNameAsync(LookupNormalizer.NormalizeName("john.nash")); + john.LastSignInTime.ShouldBeNull(); + + var lastSignInTime = DateTimeOffset.UtcNow; + await userManager.UpdateLastSignInTimeAsync(john.Id, lastSignInTime); + + john = await UserRepository.FindByNormalizedUserNameAsync(LookupNormalizer.NormalizeName("john.nash")); + john.LastSignInTime.ShouldNotBeNull(); + john.LastSignInTime.Value.ShouldBe(lastSignInTime, TimeSpan.FromSeconds(1)); + + // An older time (e.g. from a concurrent login that lost the race) should not overwrite a newer one. + await userManager.UpdateLastSignInTimeAsync(john.Id, lastSignInTime.AddMinutes(-30)); + + john = await UserRepository.FindByNormalizedUserNameAsync(LookupNormalizer.NormalizeName("john.nash")); + john.LastSignInTime!.Value.ShouldBe(lastSignInTime, TimeSpan.FromSeconds(1)); + + await userManager.UpdateLastSignInTimeAsync(Guid.NewGuid()); + } + [Fact] public async Task FindByPasskeyIdAsync() diff --git a/modules/identityserver/src/Volo.Abp.IdentityServer.Domain/Volo/Abp/IdentityServer/AspNetIdentity/AbpResourceOwnerPasswordValidator.cs b/modules/identityserver/src/Volo.Abp.IdentityServer.Domain/Volo/Abp/IdentityServer/AspNetIdentity/AbpResourceOwnerPasswordValidator.cs index b5694cb2fa..e0b2e1fcde 100644 --- a/modules/identityserver/src/Volo.Abp.IdentityServer.Domain/Volo/Abp/IdentityServer/AspNetIdentity/AbpResourceOwnerPasswordValidator.cs +++ b/modules/identityserver/src/Volo.Abp.IdentityServer.Domain/Volo/Abp/IdentityServer/AspNetIdentity/AbpResourceOwnerPasswordValidator.cs @@ -322,8 +322,7 @@ public class AbpResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator additionalClaims.ToArray() ); - user.SetLastSignInTime(DateTimeOffset.UtcNow); - await UserManager.UpdateAsync(user); + await UserManager.UpdateLastSignInTimeAsync(user.Id); await IdentitySecurityLogManager.SaveAsync( new IdentitySecurityLogContext diff --git a/modules/openiddict/src/Volo.Abp.OpenIddict.AspNetCore/Volo/Abp/OpenIddict/Controllers/TokenController.Password.cs b/modules/openiddict/src/Volo.Abp.OpenIddict.AspNetCore/Volo/Abp/OpenIddict/Controllers/TokenController.Password.cs index b076de7cd8..54b1b34abb 100644 --- a/modules/openiddict/src/Volo.Abp.OpenIddict.AspNetCore/Volo/Abp/OpenIddict/Controllers/TokenController.Password.cs +++ b/modules/openiddict/src/Volo.Abp.OpenIddict.AspNetCore/Volo/Abp/OpenIddict/Controllers/TokenController.Password.cs @@ -416,12 +416,16 @@ public partial class TokenController } ); - user.SetLastSignInTime(DateTimeOffset.UtcNow); - await UserManager.UpdateAsync(user); + await UpdateUserLastSignInTimeAsync(user); return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } + protected virtual async Task UpdateUserLastSignInTimeAsync(IdentityUser user) + { + await UserManager.UpdateLastSignInTimeAsync(user.Id); + } + protected virtual async Task IsTfaEnabledAsync(IdentityUser user) { return UserManager.SupportsUserTwoFactor &&