This commit is contained in:
2025-12-04 04:20:59 +08:00
parent 4e2bb49e86
commit 85f0cb613a
16 changed files with 243 additions and 210 deletions

View File

@@ -1,4 +1,4 @@
using Atomx.Admin.Client.Utils;
using Atomx.Admin.Client.Services;
using Atomx.Common.Models;
using Blazored.LocalStorage;
using Microsoft.AspNetCore.Components;
@@ -9,16 +9,10 @@ using System.Text.Json;
namespace Atomx.Admin.Client.Utils
{
/// <summary>
/// 请求拦截器(WASM 模式主要运行在浏览器端
/// 功能:
/// - 在每次请求时将 access token 附带到 Authorization header
/// - 在收到 401 或响应头包含 "Token-Expired" 时,尝试使用本地保存的 refresh token 调用 /api/sign/refresh
/// - 刷新成功:更新本地存储中的 accessToken/refreshToken然后重试原请求一次
/// - 刷新失败:跳转到登录页
/// 说明:
/// - 该实现依赖于 Blazored.LocalStoragekey 名称为 "accessToken" 和 "refreshToken"
/// 若你在项目中使用不同的键名,请统一替换。
/// - 为避免并发刷新,使用一个静态 SemaphoreSlim 进行序列化刷新请求。
/// WASM 模式下的请求拦截器DelegatingHandler
/// - 在每次请求时将 access token 附带 Authorization header
/// - 在 401 或响应头包含 "Token-Expired" 时尝试刷新(调用 /api/sign/refresh
/// - 防止并发刷新SemaphoreSlim
/// </summary>
public class AuthHeaderHandler : DelegatingHandler
{
@@ -29,7 +23,6 @@ namespace Atomx.Admin.Client.Utils
private readonly IHttpClientFactory _httpClientFactory;
private static readonly SemaphoreSlim _refreshLock = new(1, 1);
// 本地存储键名(可按需修改)
private const string AccessTokenKey = "accessToken";
private const string RefreshTokenKey = "refreshToken";
@@ -52,7 +45,6 @@ namespace Atomx.Admin.Client.Utils
{
try
{
// 1) 尝试从 token provider 获取并添加 Authorization header
var token = await _tokenProvider.GetTokenAsync();
if (!string.IsNullOrEmpty(token))
{
@@ -65,35 +57,29 @@ namespace Atomx.Admin.Client.Utils
var response = await base.SendAsync(request, cancellationToken);
// 2) 检查 401 或 Token-Expired header
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized ||
response.Headers.Contains("Token-Expired"))
{
_logger.LogInformation("Unauthorized or Token-Expired detected for {Url}", request.RequestUri);
// 仅在浏览器WASM模式下自动刷新在 Server 模式交由服务器端处理
if (OperatingSystem.IsBrowser())
{
var refreshed = await TryRefreshTokenAsync(cancellationToken);
if (refreshed)
{
// 获取新的 token 并重试请求(一次)
var newToken = await _localStorage.GetItemAsync<string>(AccessTokenKey);
if (!string.IsNullOrEmpty(newToken))
{
// 克隆原始请求HttpRequestMessage 只能发送一次)
var clonedRequest = await CloneHttpRequestMessageAsync(request, newToken);
return await base.SendAsync(clonedRequest, cancellationToken);
}
}
// 刷新失败,重定向登录
_navigationManager.NavigateTo("/account/login", true);
}
else
{
// Server 模式:记录日志,允许上层中间件决定下一步(不进行自动跳转)
_logger.LogWarning("Unauthorized in server mode for {Url}", request.RequestUri);
}
}
@@ -107,16 +93,8 @@ namespace Atomx.Admin.Client.Utils
}
}
/// <summary>
/// 尝试使用本地保存的 refresh token 调用刷新接口
/// API 约定:
/// POST /api/sign/refresh
/// Body: { token: "...", refreshToken: "..." }
/// 返回: AuthResponse { Token, RefreshToken, TokenExpiry }
/// </summary>
private async Task<bool> TryRefreshTokenAsync(CancellationToken cancellationToken)
{
// 串行化刷新,防止多请求同时触发重复刷新
await _refreshLock.WaitAsync(cancellationToken);
try
{
@@ -129,8 +107,7 @@ namespace Atomx.Admin.Client.Utils
return false;
}
// 调用刷新接口(使用 IHttpClientFactory 创建短期 HttpClient
var client = _httpClientFactory.CreateClient(); // 默认 client建议在 Program.cs 中配置 BaseAddress
var client = _httpClientFactory.CreateClient();
var reqModel = new
{
token = currentAccess,
@@ -163,7 +140,6 @@ namespace Atomx.Admin.Client.Utils
return false;
}
// 保存新的 token本地存储
await _localStorage.SetItemAsync(AccessTokenKey, authResp.Token, cancellationToken);
await _localStorage.SetItemAsync(RefreshTokenKey, authResp.RefreshToken, cancellationToken);
@@ -182,14 +158,10 @@ namespace Atomx.Admin.Client.Utils
}
}
/// <summary>
/// 复制 HttpRequestMessage 并替换 Authorization header 为新的 token
/// </summary>
private static async Task<HttpRequestMessage> CloneHttpRequestMessageAsync(HttpRequestMessage original, string newToken)
{
var clone = new HttpRequestMessage(original.Method, original.RequestUri);
// Copy request content (if any)
if (original.Content != null)
{
var ms = new MemoryStream();
@@ -197,7 +169,6 @@ namespace Atomx.Admin.Client.Utils
ms.Position = 0;
clone.Content = new StreamContent(ms);
// copy content headers
if (original.Content.Headers != null)
{
foreach (var h in original.Content.Headers)
@@ -205,19 +176,11 @@ namespace Atomx.Admin.Client.Utils
}
}
// copy headers
foreach (var header in original.Headers)
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
// set new auth header
clone.Headers.Authorization = new AuthenticationHeaderValue("Bearer", newToken);
// copy properties
foreach (var prop in original.Options)
{
// HttpRequestOptions 不直接序列化拷贝,这里通常无需处理
}
return clone;
}
}

View File

@@ -1,15 +1,13 @@
using Atomx.Common.Configuration;
using Atomx.Common.Constants;
using Atomx.Admin.Client.Services;
using Microsoft.JSInterop;
namespace Atomx.Admin.Client.Utils
{
public interface ITokenProvider
{
Task<string?> GetTokenAsync();
Task<bool> IsTokenValidAsync();
}
/// <summary>
/// WASM 客户端下的 Token 提供器(实现共享的 ITokenProvider
/// - 直接从浏览器 storagelocalStorage/sessionStorage读取 access token
/// - 设计为轻量,仅负责读取 token刷新逻辑放在 AuthHeaderHandler / 后端 Refresh 接口
/// </summary>
public class ClientTokenProvider : ITokenProvider
{
private readonly IJSRuntime _jsRuntime;
@@ -23,8 +21,7 @@ namespace Atomx.Admin.Client.Utils
{
try
{
// 从localStorage或sessionStorage获取token
return await _jsRuntime.InvokeAsync<string>("localStorage.getItem", StorageKeys.AccessToken);
return await _jsRuntime.InvokeAsync<string>("localStorage.getItem", "accessToken");
}
catch
{