From 2acf2ac280ff46ebaa53dd8d4e12a4cd7947fcdb Mon Sep 17 00:00:00 2001 From: lanpin <8994721+lanpin@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:08:35 +0800 Subject: [PATCH] Refactor the token verification in CookieAuthenticationOptionsExtensions to use refresh_token to re-acquire a token when it expires, and use IAbpDistributedLock. --- .../CookieAuthenticationOptionsExtensions.cs | 99 +++++++++++++++++++ .../Volo.Abp.AspNetCore.csproj | 3 +- .../Abp/AspNetCore/AbpAspNetCoreModule.cs | 6 +- 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/framework/src/Volo.Abp.AspNetCore/Microsoft/Extensions/DependencyInjection/CookieAuthenticationOptionsExtensions.cs b/framework/src/Volo.Abp.AspNetCore/Microsoft/Extensions/DependencyInjection/CookieAuthenticationOptionsExtensions.cs index 85cc987b61..4432429b58 100644 --- a/framework/src/Volo.Abp.AspNetCore/Microsoft/Extensions/DependencyInjection/CookieAuthenticationOptionsExtensions.cs +++ b/framework/src/Volo.Abp.AspNetCore/Microsoft/Extensions/DependencyInjection/CookieAuthenticationOptionsExtensions.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Volo.Abp.DistributedLocking; using Volo.Abp.Threading; namespace Microsoft.Extensions.DependencyInjection; @@ -35,6 +36,104 @@ public static class CookieAuthenticationOptionsExtensions if (!tokenExpiresAt.IsNullOrWhiteSpace() && DateTimeOffset.TryParseExact(tokenExpiresAt, "o", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var expiresAt) && expiresAt <= DateTimeOffset.UtcNow.Add(advance.Value)) { + var refreshToken = principalContext.Properties.GetTokenValue("refresh_token"); + if (refreshToken.IsNullOrWhiteSpace()) + { + await SignOutAndInvokePreviousHandlerAsync(principalContext, previousHandler); + return; + } + + logger.LogInformation("The access_token expires within {AdvanceSeconds}s but a refresh_token is available; attempting to refresh.", advance.Value.TotalSeconds); + + var openIdConnectOptions = await GetOpenIdConnectOptions(principalContext, oidcAuthenticationScheme); + + var tokenEndpoint = openIdConnectOptions.Configuration?.TokenEndpoint; + if (tokenEndpoint.IsNullOrWhiteSpace() && !openIdConnectOptions.Authority.IsNullOrWhiteSpace()) + { + tokenEndpoint = openIdConnectOptions.Authority.EnsureEndsWith('/') + "connect/token"; + } + + if (tokenEndpoint.IsNullOrWhiteSpace()) + { + logger.LogWarning("No token endpoint configured. Skipping token refresh."); + await SignOutAndInvokePreviousHandlerAsync(principalContext, previousHandler); + return; + } + + var clientId = principalContext.Properties.GetString("client_id"); + var clientSecret = principalContext.Properties.GetString("client_secret"); + + var refreshRequest = new RefreshTokenRequest + { + Address = tokenEndpoint, + ClientId = clientId ?? openIdConnectOptions.ClientId!, + ClientSecret = clientSecret ?? openIdConnectOptions.ClientSecret, + RefreshToken = refreshToken + }; + + var cancellationTokenProvider = principalContext.HttpContext.RequestServices.GetRequiredService(); + + const int RefreshTokenLockTimeoutSeconds = 3; + const string RefreshTokenLockKeyFormat = "refresh_token_lock_{0}"; + + var userKey = + principalContext.Principal?.FindFirst("sub")?.Value + ?? principalContext.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value + ?? "unknown"; + + var lockKey = string.Format(CultureInfo.InvariantCulture, RefreshTokenLockKeyFormat, userKey); + var lockTimeout = TimeSpan.FromSeconds(RefreshTokenLockTimeoutSeconds); + + var abpDistributedLock = principalContext.HttpContext.RequestServices.GetRequiredService(); + + await using (var handle = await abpDistributedLock.TryAcquireAsync(lockKey, lockTimeout, cancellationTokenProvider.Token)) + { + if (handle != null) + { + var response = await openIdConnectOptions.Backchannel.RequestRefreshTokenAsync(refreshRequest, cancellationTokenProvider.Token); + + if (response.IsError) + { + logger.LogError("Token refresh failed: {Error}", response.Error); + await SignOutAndInvokePreviousHandlerAsync(principalContext, previousHandler); + return; + } + + if (response.ExpiresIn <= 0) + { + logger.LogWarning("The token endpoint response does not contain a valid expires_in value. Skipping token refresh."); + await SignOutAndInvokePreviousHandlerAsync(principalContext, previousHandler); + return; + } + + if (response.AccessToken.IsNullOrWhiteSpace()) + { + logger.LogWarning("The token endpoint response does not contain a new access_token. Skipping token refresh."); + await SignOutAndInvokePreviousHandlerAsync(principalContext, previousHandler); + return; + } + + if (response.RefreshToken.IsNullOrWhiteSpace()) + { + logger.LogInformation("The token endpoint response does not contain a new refresh_token. The old refresh_token will continue to be used until it expires."); + } + + logger.LogInformation("Token refreshed successfully. Updating cookie with new tokens."); + var newTokens = new[] + { + new AuthenticationToken { Name = "access_token", Value = response.AccessToken }, + new AuthenticationToken { Name = "refresh_token", Value = response.RefreshToken ?? refreshToken }, + new AuthenticationToken { Name = "expires_at", Value = DateTimeOffset.UtcNow.AddSeconds(response.ExpiresIn).ToString("o", CultureInfo.InvariantCulture) } + }; + + principalContext.Properties.StoreTokens(newTokens); + principalContext.ShouldRenew = true; + + await InvokePreviousHandlerAsync(principalContext, previousHandler); + return; + } + } + logger.LogInformation("The access_token expires within {AdvanceSeconds}s; signing out.", advance.Value.TotalSeconds); await SignOutAndInvokePreviousHandlerAsync(principalContext, previousHandler); return; diff --git a/framework/src/Volo.Abp.AspNetCore/Volo.Abp.AspNetCore.csproj b/framework/src/Volo.Abp.AspNetCore/Volo.Abp.AspNetCore.csproj index 9affb2a326..9e9564e943 100644 --- a/framework/src/Volo.Abp.AspNetCore/Volo.Abp.AspNetCore.csproj +++ b/framework/src/Volo.Abp.AspNetCore/Volo.Abp.AspNetCore.csproj @@ -1,4 +1,4 @@ - + @@ -23,6 +23,7 @@ + diff --git a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/AbpAspNetCoreModule.cs b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/AbpAspNetCoreModule.cs index a457d3b1eb..3f89c6ad85 100644 --- a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/AbpAspNetCoreModule.cs +++ b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/AbpAspNetCoreModule.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.StaticWebAssets; using Microsoft.AspNetCore.RequestLocalization; @@ -18,6 +18,7 @@ using Volo.Abp.Security; using Volo.Abp.Uow; using Volo.Abp.Validation; using Volo.Abp.VirtualFileSystem; +using Volo.Abp.DistributedLocking; namespace Volo.Abp.AspNetCore; @@ -30,7 +31,8 @@ namespace Volo.Abp.AspNetCore; typeof(AbpAuthorizationModule), typeof(AbpValidationModule), typeof(AbpExceptionHandlingModule), - typeof(AbpAspNetCoreAbstractionsModule) + typeof(AbpAspNetCoreAbstractionsModule), + typeof(AbpDistributedLockingAbstractionsModule) )] public class AbpAspNetCoreModule : AbpModule {