From 17f1c360715b47e2c3cbe1801276d489e8ac9ecc Mon Sep 17 00:00:00 2001 From: Ezreal Date: Fri, 5 Dec 2025 21:58:15 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=A4=BE=E5=8C=BA#276?= =?UTF-8?q?=E7=9A=84=E6=8F=90=E8=AE=AE(by=20qoder)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OAuthTokenOptionsExtensions.cs | 41 +++ .../HttpApiOptionsExtensions.cs | 45 +++ .../OAuthTokenOptions.cs | 34 +++ .../RefreshWindowStrategy.cs | 23 ++ .../TokenProviders/TokenProvider.cs | 92 +++++- .../TokenResult.cs | 14 +- .../Extensions/OAuths/TokenResultTest.cs | 267 ++++++++++++++++++ .../WebApiClientCore.Test.csproj | 1 + docs/guide/6_auth-token-extension.md | 76 +++++ 9 files changed, 591 insertions(+), 2 deletions(-) create mode 100644 WebApiClientCore.Extensions.OAuths/DependencyInjection/OAuthTokenOptionsExtensions.cs create mode 100644 WebApiClientCore.Extensions.OAuths/HttpApiOptionsExtensions.cs create mode 100644 WebApiClientCore.Extensions.OAuths/OAuthTokenOptions.cs create mode 100644 WebApiClientCore.Extensions.OAuths/RefreshWindowStrategy.cs create mode 100644 WebApiClientCore.Test/Extensions/OAuths/TokenResultTest.cs diff --git a/WebApiClientCore.Extensions.OAuths/DependencyInjection/OAuthTokenOptionsExtensions.cs b/WebApiClientCore.Extensions.OAuths/DependencyInjection/OAuthTokenOptionsExtensions.cs new file mode 100644 index 00000000..ad8c714e --- /dev/null +++ b/WebApiClientCore.Extensions.OAuths/DependencyInjection/OAuthTokenOptionsExtensions.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.DependencyInjection; +using System; + +namespace WebApiClientCore.Extensions.OAuths.DependencyInjection +{ + /// + /// OAuthToken配置的依赖注入扩展 + /// + public static class OAuthTokenOptionsExtensions + { + /// + /// 配置OAuth Token刷新选项 + /// 使用独立的配置选项,支持从appsettings.json加载 + /// + /// HttpClient构建器 + /// 配置委托 + /// HttpClient构建器 + public static IHttpClientBuilder ConfigureOAuthTokenOptions( + this IHttpClientBuilder builder, + Action configure) + { + builder.Services.Configure(builder.Name, configure); + return builder; + } + + /// + /// 配置OAuth Token刷新选项(通过IConfiguration) + /// 支持从appsettings.json等配置源加载 + /// + /// HttpClient构建器 + /// 配置节 + /// HttpClient构建器 + public static IHttpClientBuilder ConfigureOAuthTokenOptions( + this IHttpClientBuilder builder, + Microsoft.Extensions.Configuration.IConfiguration configuration) + { + builder.Services.Configure(builder.Name, configuration); + return builder; + } + } +} diff --git a/WebApiClientCore.Extensions.OAuths/HttpApiOptionsExtensions.cs b/WebApiClientCore.Extensions.OAuths/HttpApiOptionsExtensions.cs new file mode 100644 index 00000000..7c81bcc6 --- /dev/null +++ b/WebApiClientCore.Extensions.OAuths/HttpApiOptionsExtensions.cs @@ -0,0 +1,45 @@ +using System; + +namespace WebApiClientCore.Extensions.OAuths +{ + /// + /// HttpApiOptions的扩展方法 + /// + public static class HttpApiOptionsExtensions + { + /// + /// OAuthTokenOptions在Properties字典中的键 + /// + private const string TokenRefreshOptionsKey = "WebApiClientCore.OAuths.TokenRefreshOptions"; + + /// + /// 获取或设置OAuth Token刷新选项 + /// 此方法使用HttpApiOptions的Properties字典存储配置,不污染核心配置类 + /// + /// HttpApi选项 + /// OAuth Token刷新选项 + public static OAuthTokenOptions GetOAuthTokenOptions(this HttpApiOptions options) + { + if (!options.Properties.TryGetValue(TokenRefreshOptionsKey, out var value)) + { + value = new OAuthTokenOptions(); + options.Properties[TokenRefreshOptionsKey] = value; + } + return (OAuthTokenOptions)value; + } + + /// + /// 配置OAuth Token刷新选项 + /// 此方法使用HttpApiOptions的Properties字典存储配置,不污染核心配置类 + /// + /// HttpApi选项 + /// 配置委托 + /// HttpApi选项 + public static HttpApiOptions ConfigureOAuthToken(this HttpApiOptions options, Action configure) + { + var tokenOptions = options.GetOAuthTokenOptions(); + configure(tokenOptions); + return options; + } + } +} diff --git a/WebApiClientCore.Extensions.OAuths/OAuthTokenOptions.cs b/WebApiClientCore.Extensions.OAuths/OAuthTokenOptions.cs new file mode 100644 index 00000000..d93cce4d --- /dev/null +++ b/WebApiClientCore.Extensions.OAuths/OAuthTokenOptions.cs @@ -0,0 +1,34 @@ +namespace WebApiClientCore.Extensions.OAuths +{ + /// + /// 表示OAuth Token的刷新选项 + /// + public class OAuthTokenOptions + { + /// + /// 获取或设置是否启用Token提前刷新窗口 + /// 默认值为 true + /// + public bool UseTokenRefreshWindow { get; set; } = true; + + /// + /// 获取或设置固定刷新窗口时长(秒) + /// 当剩余有效时间小于等于此值时,触发提前刷新 + /// 默认值为 60 秒 + /// + public int RefreshWindowSeconds { get; set; } = 60; + + /// + /// 获取或设置刷新窗口百分比(0-1) + /// 当剩余有效时间小于等于总有效期的此百分比时,触发提前刷新 + /// 默认值为 0.1 (10%) + /// + public double RefreshWindowPercentage { get; set; } = 0.1; + + /// + /// 获取或设置刷新窗口计算策略 + /// 默认值为 Auto (自动选择固定时长和百分比中的较小值) + /// + public RefreshWindowStrategy RefreshWindowStrategy { get; set; } = RefreshWindowStrategy.Auto; + } +} diff --git a/WebApiClientCore.Extensions.OAuths/RefreshWindowStrategy.cs b/WebApiClientCore.Extensions.OAuths/RefreshWindowStrategy.cs new file mode 100644 index 00000000..48ae357c --- /dev/null +++ b/WebApiClientCore.Extensions.OAuths/RefreshWindowStrategy.cs @@ -0,0 +1,23 @@ +namespace WebApiClientCore.Extensions.OAuths +{ + /// + /// 表示Token刷新窗口计算策略 + /// + public enum RefreshWindowStrategy + { + /// + /// 仅使用固定秒数 + /// + FixedSeconds = 0, + + /// + /// 仅使用百分比 + /// + Percentage = 1, + + /// + /// 自动选择:取固定秒数和百分比的较小值 + /// + Auto = 2 + } +} diff --git a/WebApiClientCore.Extensions.OAuths/TokenProviders/TokenProvider.cs b/WebApiClientCore.Extensions.OAuths/TokenProviders/TokenProvider.cs index 1ea0ec94..a89b6f4e 100644 --- a/WebApiClientCore.Extensions.OAuths/TokenProviders/TokenProvider.cs +++ b/WebApiClientCore.Extensions.OAuths/TokenProviders/TokenProvider.cs @@ -75,7 +75,7 @@ public async Task GetTokenAsync() using var scope = this.services.CreateScope(); this.token = await this.RequestTokenAsync(scope.ServiceProvider).ConfigureAwait(false); } - else if (this.token.IsExpired() == true) + else if (this.ShouldRefreshToken(this.token)) { using var scope = this.services.CreateScope(); @@ -88,6 +88,96 @@ public async Task GetTokenAsync() } } + /// + /// 判断是否应该刷新Token + /// 混合方案:优先从独立配置读取,其次从 HttpApiOptions.Properties 读取,最后使用默认行为 + /// + /// token结果 + /// + private bool ShouldRefreshToken(TokenResult token) + { + // 获取配置 + var tokenOptions = this.GetTokenRefreshOptions(); + + if (!tokenOptions.UseTokenRefreshWindow) + { + // 如果禁用刷新窗口,使用原有行为 + return token.IsExpired(); + } + + // 计算刷新窗口 + var refreshWindow = this.CalculateRefreshWindow(token, tokenOptions); + return token.IsExpired(refreshWindow); + } + + /// + /// 获取Token刷新配置 + /// 优先级:独立配置(IOptionsMonitor<OAuthTokenOptions>) > HttpApiOptions.Properties > 默认配置 + /// + /// Token刷新配置 + private OAuthTokenOptions GetTokenRefreshOptions() + { + // 优先从独立配置读取 (方案2:独立配置类) + var oauthOptionsMonitor = this.services.GetService>(); + if (oauthOptionsMonitor != null) + { + try + { + var options = oauthOptionsMonitor.Get(this.Name); + // 检查是否为默认配置,如果不是则使用 + if (options != null) + { + return options; + } + } + catch + { + // 如果获取失败,继续尝试下一种方式 + } + } + + // 其次从 HttpApiOptions.Properties 读取 (方案1:Properties 字典) + var httpApiOptionsMonitor = this.services.GetService>(); + if (httpApiOptionsMonitor != null) + { + try + { + var httpApiOptions = httpApiOptionsMonitor.Get(this.Name); + if (httpApiOptions != null) + { + return httpApiOptions.GetOAuthTokenOptions(); + } + } + catch + { + // 如果获取失败,使用默认配置 + } + } + + // 最后使用默认配置 + return new OAuthTokenOptions(); + } + + /// + /// 根据配置策略计算刷新窗口 + /// + /// Token结果 + /// 配置选项 + /// 刷新窗口时间 + private TimeSpan CalculateRefreshWindow(TokenResult token, OAuthTokenOptions options) + { + var fixedWindow = TimeSpan.FromSeconds(options.RefreshWindowSeconds); + var percentageWindow = TimeSpan.FromSeconds(token.Expires_in * options.RefreshWindowPercentage); + + return options.RefreshWindowStrategy switch + { + RefreshWindowStrategy.FixedSeconds => fixedWindow, + RefreshWindowStrategy.Percentage => percentageWindow, + RefreshWindowStrategy.Auto => fixedWindow < percentageWindow ? fixedWindow : percentageWindow, + _ => fixedWindow < percentageWindow ? fixedWindow : percentageWindow // 默认使用Auto + }; + } + /// /// 请求获取 token diff --git a/WebApiClientCore.Extensions.OAuths/TokenResult.cs b/WebApiClientCore.Extensions.OAuths/TokenResult.cs index 71f312e3..04b0c9e0 100644 --- a/WebApiClientCore.Extensions.OAuths/TokenResult.cs +++ b/WebApiClientCore.Extensions.OAuths/TokenResult.cs @@ -75,7 +75,19 @@ public virtual bool IsSuccess() /// public virtual bool IsExpired() { - return DateTime.Now.Subtract(this.createTime) > TimeSpan.FromSeconds(this.Expires_in); + return IsExpired(TimeSpan.Zero); + } + + /// + /// 返回是否已过期或在刷新窗口内 + /// + /// 提前刷新时间窗口 + /// 如果已过期或剩余有效时间小于等于刷新窗口,返回true + public virtual bool IsExpired(TimeSpan refreshWindow) + { + var elapsed = DateTime.Now.Subtract(this.createTime); + var remaining = TimeSpan.FromSeconds(this.Expires_in).Subtract(elapsed); + return remaining <= refreshWindow; } /// diff --git a/WebApiClientCore.Test/Extensions/OAuths/TokenResultTest.cs b/WebApiClientCore.Test/Extensions/OAuths/TokenResultTest.cs new file mode 100644 index 00000000..19e0e188 --- /dev/null +++ b/WebApiClientCore.Test/Extensions/OAuths/TokenResultTest.cs @@ -0,0 +1,267 @@ +using System; +using System.Threading; +using WebApiClientCore.Extensions.OAuths; +using Xunit; + +namespace WebApiClientCore.Test.Extensions.OAuths +{ + /// + /// TokenResult 过期判断测试 + /// + public class TokenResultTest + { + #region 基础过期判断测试 + + [Fact] + [Trait("Category", "BasicExpiration")] + public void IsExpired_WithValidToken_ReturnsFalse() + { + // Arrange + var token = new TokenResult { Expires_in = 3600 }; + + // Act + var result = token.IsExpired(); + + // Assert + Assert.False(result); + } + + [Fact] + [Trait("Category", "BasicExpiration")] + public void IsExpired_WithExpiredToken_ReturnsTrue() + { + // Arrange + var token = new TokenResult { Expires_in = 1 }; + Thread.Sleep(1100); // 等待超过1秒 + + // Act + var result = token.IsExpired(); + + // Assert + Assert.True(result); + } + + [Fact] + [Trait("Category", "BasicExpiration")] + public void IsExpired_AtExactExpirationTime_ReturnsTrue() + { + // Arrange + var token = new TokenResult { Expires_in = 0 }; + + // Act + var result = token.IsExpired(); + + // Assert + Assert.True(result, "Token with expires_in=0 should be immediately expired"); + } + + #endregion + + #region 刷新窗口过期判断测试 + + [Fact] + [Trait("Category", "RefreshWindow")] + public void IsExpired_OutsideRefreshWindow_ReturnsFalse() + { + // Arrange + var token = new TokenResult { Expires_in = 300 }; // 5 minutes + var refreshWindow = TimeSpan.FromSeconds(60); // 1 minute + + // Act + var result = token.IsExpired(refreshWindow); + + // Assert + Assert.False(result, "Token with 5 minutes remaining should not trigger refresh with 1 minute window"); + } + + [Fact] + [Trait("Category", "RefreshWindow")] + public void IsExpired_JustEnteredRefreshWindow_ReturnsTrue() + { + // Arrange + var token = new TokenResult { Expires_in = 2 }; // 2 seconds + var refreshWindow = TimeSpan.FromSeconds(60); // 1 minute + Thread.Sleep(100); // 等待一小段时间 + + // Act + var result = token.IsExpired(refreshWindow); + + // Assert + Assert.True(result, "Token with 2 seconds lifetime should be in refresh window immediately with 60 second window"); + } + + [Fact] + [Trait("Category", "RefreshWindow")] + public void IsExpired_AtRefreshWindowBoundary_ReturnsTrue() + { + // Arrange + var token = new TokenResult { Expires_in = 60 }; // Exactly 60 seconds + var refreshWindow = TimeSpan.FromSeconds(60); + + // Act + var result = token.IsExpired(refreshWindow); + + // Assert + Assert.True(result, "Token at exact refresh window boundary should trigger refresh"); + } + + [Theory] + [InlineData(30, false)] // 120秒有效期,30秒窗口,剩余120秒 > 30秒,不在窗口内 + [InlineData(60, false)] // 120秒有效期,60秒窗口,剩余120秒 > 60秒,不在窗口内 + [InlineData(90, false)] // 120秒有效期,90秒窗口,剩余120秒 > 90秒,不在窗口内 + [InlineData(120, true)] // 120秒有效期,120秒窗口,剩余120秒 = 120秒,在窗口内(临界点) + [Trait("Category", "RefreshWindow")] + public void IsExpired_WithDifferentRefreshWindows_BehavesCorrectly(int windowSeconds, bool expectedExpired) + { + // Arrange + var token = new TokenResult { Expires_in = 120 }; // 2 minutes + var refreshWindow = TimeSpan.FromSeconds(windowSeconds); + + // Act + var result = token.IsExpired(refreshWindow); + + // Assert + Assert.Equal(expectedExpired, result); + } + + [Fact] + [Trait("Category", "RefreshWindow")] + public void IsExpired_AfterActualExpiration_ReturnsTrueRegardlessOfWindow() + { + // Arrange + var token = new TokenResult { Expires_in = 1 }; + Thread.Sleep(1100); // 等待过期 + var refreshWindow = TimeSpan.Zero; + + // Act + var result = token.IsExpired(refreshWindow); + + // Assert + Assert.True(result, "Expired token should return true even with zero refresh window"); + } + + #endregion + + #region 边界情况测试 + + [Fact] + [Trait("Category", "EdgeCase")] + public void IsExpired_WithZeroExpiresIn_ReturnsTrue() + { + // Arrange + var token = new TokenResult { Expires_in = 0 }; + + // Act + var result = token.IsExpired(); + + // Assert + Assert.True(result, "Token with expires_in=0 should be immediately expired"); + } + + [Fact] + [Trait("Category", "EdgeCase")] + public void IsExpired_WithNegativeExpiresIn_ReturnsTrue() + { + // Arrange + var token = new TokenResult { Expires_in = -100 }; + + // Act + var result = token.IsExpired(); + + // Assert + Assert.True(result, "Token with negative expires_in should be expired"); + } + + [Fact] + [Trait("Category", "EdgeCase")] + public void IsExpired_RefreshWindowLargerThanExpiresIn_ReturnsTrueImmediately() + { + // Arrange + var token = new TokenResult { Expires_in = 60 }; // 1 minute + var refreshWindow = TimeSpan.FromSeconds(120); // 2 minutes + + // Act + var result = token.IsExpired(refreshWindow); + + // Assert + Assert.True(result, "When refresh window is larger than expires_in, token should be in refresh window immediately"); + } + + [Fact] + [Trait("Category", "EdgeCase")] + public void IsExpired_VeryShortExpiresIn_BehavesCorrectly() + { + // Arrange + var token = new TokenResult { Expires_in = 2 }; // 2 seconds + var refreshWindow = TimeSpan.FromSeconds(60); + + // Act + var result = token.IsExpired(refreshWindow); + + // Assert + Assert.True(result, "Very short lifetime token should not cause exceptions"); + } + + #endregion + + #region 时间流逝模拟测试 + + [Fact] + [Trait("Category", "TimeProgression")] + public void IsExpired_ProgressionFromValidToRefreshWindow_TransitionsCorrectly() + { + // Arrange + var token = new TokenResult { Expires_in = 3 }; // 3 seconds + var refreshWindow = TimeSpan.FromSeconds(2); + + // Act & Assert - Initially outside refresh window + Assert.False(token.IsExpired(refreshWindow)); + + // Wait to enter refresh window + Thread.Sleep(1100); // After 1 second, remaining = 2 seconds + Assert.True(token.IsExpired(refreshWindow), "Should be in refresh window after 1 second"); + } + + [Fact] + [Trait("Category", "TimeProgression")] + public void IsExpired_ProgressionFromRefreshWindowToExpired_RemainsTrue() + { + // Arrange + var token = new TokenResult { Expires_in = 2 }; // 2 seconds + var refreshWindow = TimeSpan.FromSeconds(1); + + // Act & Assert - Check at different points + Thread.Sleep(1100); // After 1 second, in refresh window + Assert.True(token.IsExpired(refreshWindow)); + + Thread.Sleep(1000); // After 2 seconds, actually expired + Assert.True(token.IsExpired(refreshWindow)); + } + + [Fact] + [Trait("Category", "TimeProgression")] + public void IsExpired_MultipleChecks_ConsistentResults() + { + // Arrange + var token = new TokenResult { Expires_in = 3600 }; + bool? firstResult = null; + + // Act - Multiple rapid checks + for (int i = 0; i < 100; i++) + { + var result = token.IsExpired(); + if (!firstResult.HasValue) + { + firstResult = result; + } + else + { + // Assert + Assert.Equal(firstResult.Value, result); + } + } + } + + #endregion + } +} diff --git a/WebApiClientCore.Test/WebApiClientCore.Test.csproj b/WebApiClientCore.Test/WebApiClientCore.Test.csproj index ec82a1a8..9e5a4401 100644 --- a/WebApiClientCore.Test/WebApiClientCore.Test.csproj +++ b/WebApiClientCore.Test/WebApiClientCore.Test.csproj @@ -21,5 +21,6 @@ + diff --git a/docs/guide/6_auth-token-extension.md b/docs/guide/6_auth-token-extension.md index 5628c1c0..c1406551 100644 --- a/docs/guide/6_auth-token-extension.md +++ b/docs/guide/6_auth-token-extension.md @@ -14,6 +14,82 @@ token 的应用特性,使用 ITokenProviderFactory 创建 ITokenProvider,然 ### OAuthTokenHandler 属于 http 消息处理器,功能与 OAuthTokenAttribute 一样,除此之外,如果因为意外的原因导致服务器仍然返回未授权(401 状态码),其还会丢弃旧 token,申请新 token 来重试一次请求。 + +## Token 提前刷新 + +为避免 token 在检查后到实际使用前过期,支持配置提前刷新时间窗口。 + +### 默认行为 + +默认启用提前刷新,使用 Auto 策略: +- 固定窗口:60 秒 +- 百分比窗口:10% +- 实际窗口:取两者较小值 + +### 配置方式1:通过 HttpApiOptions + +```csharp +services.AddHttpApi(o => +{ + o.ConfigureOAuthToken(t => + { + t.RefreshWindowSeconds = 120; // 固定窗口120秒 + t.RefreshWindowPercentage = 0.15; // 百分比15% + t.RefreshWindowStrategy = RefreshWindowStrategy.Auto; + }); +}); +``` + +### 配置方式2:通过独立配置 + +```csharp +services.AddHttpApi() + .AddOAuthTokenHandler() + .ConfigureOAuthTokenOptions(o => + { + o.RefreshWindowSeconds = 120; + o.RefreshWindowStrategy = RefreshWindowStrategy.FixedSeconds; + }); +``` + +### 从配置文件加载 + +**appsettings.json:** +```json +{ + "OAuthToken": { + "IUserApi": { + "UseTokenRefreshWindow": true, + "RefreshWindowSeconds": 120, + "RefreshWindowPercentage": 0.15, + "RefreshWindowStrategy": "Auto" + } + } +} +``` + +**Program.cs:** +```csharp +services.AddHttpApi() + .ConfigureOAuthTokenOptions(configuration.GetSection("OAuthToken:IUserApi")); +``` + +### 刷新策略 + +| 策略 | 说明 | 适用场景 | +|------|------|----------| +| `FixedSeconds` | 固定秒数 | Token 有效期固定 | +| `Percentage` | 百分比 | Token 有效期不固定 | +| `Auto` (默认) | 取较小值 | 通用场景 | + +### 禁用提前刷新 + +```csharp +services.AddHttpApi(o => +{ + o.ConfigureOAuthToken(t => t.UseTokenRefreshWindow = false); +}); +``` ## OAuth 的 Client 模式