Files
Atomx/Atomx.Admin/Atomx.Admin.Client/Utils/AuthHeaderHandler.cs
2025-12-04 03:08:29 +08:00

224 lines
9.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Atomx.Admin.Client.Utils;
using Atomx.Common.Models;
using Blazored.LocalStorage;
using Microsoft.AspNetCore.Components;
using System.Net.Http.Headers;
using System.Text;
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 进行序列化刷新请求。
/// </summary>
public class AuthHeaderHandler : DelegatingHandler
{
private readonly ITokenProvider _tokenProvider;
private readonly NavigationManager _navigationManager;
private readonly ILogger<AuthHeaderHandler> _logger;
private readonly ILocalStorageService _localStorage;
private readonly IHttpClientFactory _httpClientFactory;
private static readonly SemaphoreSlim _refreshLock = new(1, 1);
// 本地存储键名(可按需修改)
private const string AccessTokenKey = "accessToken";
private const string RefreshTokenKey = "refreshToken";
public AuthHeaderHandler(
ITokenProvider tokenProvider,
NavigationManager navigationManager,
ILogger<AuthHeaderHandler> logger,
ILocalStorageService localStorage,
IHttpClientFactory httpClientFactory)
{
_tokenProvider = tokenProvider;
_navigationManager = navigationManager;
_logger = logger;
_localStorage = localStorage;
_httpClientFactory = httpClientFactory;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
try
{
// 1) 尝试从 token provider 获取并添加 Authorization header
var token = await _tokenProvider.GetTokenAsync();
if (!string.IsNullOrEmpty(token))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
else
{
_logger.LogDebug("No token from ITokenProvider for request {Url}", request.RequestUri);
}
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);
}
}
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending HTTP request to {Url}", request.RequestUri);
throw;
}
}
/// <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
{
var currentAccess = await _localStorage.GetItemAsync<string>(AccessTokenKey);
var currentRefresh = await _localStorage.GetItemAsync<string>(RefreshTokenKey);
if (string.IsNullOrEmpty(currentAccess) || string.IsNullOrEmpty(currentRefresh))
{
_logger.LogInformation("No local tokens to refresh");
return false;
}
// 调用刷新接口(使用 IHttpClientFactory 创建短期 HttpClient
var client = _httpClientFactory.CreateClient(); // 默认 client建议在 Program.cs 中配置 BaseAddress
var reqModel = new
{
token = currentAccess,
refreshToken = currentRefresh
};
var reqJson = JsonSerializer.Serialize(reqModel);
using var req = new HttpRequestMessage(HttpMethod.Post, "api/sign/refresh")
{
Content = new StringContent(reqJson, Encoding.UTF8, "application/json")
};
try
{
var resp = await client.SendAsync(req, cancellationToken);
if (!resp.IsSuccessStatusCode)
{
_logger.LogWarning("Refresh request failed with status {Status}", resp.StatusCode);
return false;
}
var json = await resp.Content.ReadAsStringAsync(cancellationToken);
var authResp = JsonSerializer.Deserialize<AuthResponse>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (authResp == null || string.IsNullOrEmpty(authResp.Token) || string.IsNullOrEmpty(authResp.RefreshToken))
{
_logger.LogWarning("Invalid response from refresh endpoint");
return false;
}
// 保存新的 token本地存储
await _localStorage.SetItemAsync(AccessTokenKey, authResp.Token, cancellationToken);
await _localStorage.SetItemAsync(RefreshTokenKey, authResp.RefreshToken, cancellationToken);
_logger.LogInformation("Token refreshed successfully");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception while requesting token refresh");
return false;
}
}
finally
{
_refreshLock.Release();
}
}
/// <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();
await original.Content.CopyToAsync(ms).ConfigureAwait(false);
ms.Position = 0;
clone.Content = new StreamContent(ms);
// copy content headers
if (original.Content.Headers != null)
{
foreach (var h in original.Content.Headers)
clone.Content.Headers.Add(h.Key, h.Value);
}
}
// 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;
}
}
}