fix chore
This commit is contained in:
@@ -111,7 +111,7 @@ else
|
||||
Console.WriteLine("请求api成功");
|
||||
if (!string.IsNullOrEmpty(result.Data))
|
||||
{
|
||||
await localStorage.SetItemAsStringAsync(StorageKeys.JWTTokenKeyName, result.Data);
|
||||
await localStorage.SetItemAsStringAsync(StorageKeys.AccessToken, result.Data);
|
||||
await localStorage.SetItemAsStringAsync("refreshToken", result.Data);
|
||||
var authState = (AuthStateProvider as PersistentAuthenticationStateProvider);
|
||||
if (authState != null)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@page "/admin/list"
|
||||
@using Atomx.Common.Constants
|
||||
@inject ILogger<AdminList> Logger
|
||||
@* @attribute [Authorize] *@
|
||||
|
||||
|
||||
@@ -6,17 +6,39 @@ using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
|
||||
// ע<>᱾<EFBFBD>ش洢<D8B4><E6B4A2><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
||||
builder.Services.AddBlazoredLocalStorageAsSingleton();
|
||||
|
||||
// <20><>Ȩ/<2F><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
||||
builder.Services.AddAuthorizationCore();
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddSingleton<AuthenticationStateProvider, PersistentAuthenticationStateProvider>();
|
||||
|
||||
// Ȩ<><C8A8> & <20><><EFBFBD>ػ<EFBFBD>
|
||||
builder.Services.AddScoped<IPermissionService, ClientPermissionService>();
|
||||
builder.Services.AddScoped<IconsExtension>();
|
||||
builder.Services.AddScoped<ILocalizationService, LocalizationClientService>();
|
||||
|
||||
// Token provider<65><72>WASM<53><4D>
|
||||
builder.Services.AddScoped<ITokenProvider, ClientTokenProvider>();
|
||||
|
||||
// ע<><D7A2><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Զ<EFBFBD><D4B6><EFBFBD><EFBFBD><EFBFBD> token & ˢ<>µ<EFBFBD> DelegatingHandler
|
||||
builder.Services.AddScoped<AuthHeaderHandler>();
|
||||
builder.Services.AddHttpClientApiService(builder.Configuration["WebApi:ServerUrl"] ?? builder.HostEnvironment.BaseAddress);
|
||||
|
||||
// ע<><D7A2>һ<EFBFBD><D2BB><EFBFBD><EFBFBD><EFBFBD><EFBFBD> HttpClient<6E><74><EFBFBD><EFBFBD>Ӧ<EFBFBD><D3A6><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> API <20><><EFBFBD><EFBFBD>ʹ<EFBFBD>ã<EFBFBD><C3A3><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> AuthHeaderHandler <20><><EFBFBD><EFBFBD><EFBFBD>ܵ<EFBFBD>
|
||||
var apiBase = builder.Configuration["WebApi:ServerUrl"] ?? builder.HostEnvironment.BaseAddress;
|
||||
builder.Services.AddHttpClient("ApiClient", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(apiBase);
|
||||
})
|
||||
.AddHttpMessageHandler<AuthHeaderHandler>();
|
||||
|
||||
// Ϊ<><CEAA><EFBFBD><EFBFBD>ע<EFBFBD><D7A2>δ<EFBFBD><CEB4><EFBFBD><EFBFBD><EFBFBD>ֵ<EFBFBD> HttpClient<6E><74>AuthHeaderHandler <20>ڲ<EFBFBD> CreateClient() ʹ<><CAB9>Ĭ<EFBFBD>Ϲ<EFBFBD><CFB9><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
||||
// Ҳע<D2B2><D7A2>Ĭ<EFBFBD><C4AC> HttpClient <20><> BaseAddress
|
||||
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("ApiClient"));
|
||||
|
||||
// <20><> WASM DI <20><>ע<EFBFBD><D7A2> HttpService<63><65>ʹ<EFBFBD><CAB9><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ע<EFBFBD><D7A2><EFBFBD><EFBFBD> HttpClient ʵ<><CAB5>
|
||||
builder.Services.AddScoped<HttpService>(sp => new HttpService(sp.GetRequiredService<HttpClient>()));
|
||||
|
||||
builder.Services.AddAntDesign();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Atomx.Common.Utils;
|
||||
using Atomx.Common.Constants;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
|
||||
@@ -28,7 +28,6 @@ namespace Atomx.Admin.Client.Services
|
||||
public class LocalizationClientService : ILocalizationService, IAsyncDisposable
|
||||
{
|
||||
private readonly HttpService _httpService;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
private readonly ILogger<LocalizationClientService> _logger;
|
||||
|
||||
@@ -40,12 +39,10 @@ namespace Atomx.Admin.Client.Services
|
||||
|
||||
public LocalizationClientService(
|
||||
HttpService httpService,
|
||||
HttpClient httpClient,
|
||||
IJSRuntime jsRuntime,
|
||||
ILogger<LocalizationClientService> logger)
|
||||
{
|
||||
_httpService = httpService;
|
||||
_httpClient = httpClient;
|
||||
_jsRuntime = jsRuntime;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,50 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
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.LocalStorage(key 名称为 "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)
|
||||
ILogger<AuthHeaderHandler> logger,
|
||||
ILocalStorageService localStorage,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_tokenProvider = tokenProvider;
|
||||
_navigationManager = navigationManager;
|
||||
_logger = logger;
|
||||
_localStorage = localStorage;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
@@ -23,25 +52,50 @@ namespace Atomx.Admin.Client.Utils
|
||||
{
|
||||
try
|
||||
{
|
||||
// 获取token
|
||||
// 1) 尝试从 token provider 获取并添加 Authorization header
|
||||
var token = await _tokenProvider.GetTokenAsync();
|
||||
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
request.Headers.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No authentication token available for request: {Url}", request.RequestUri);
|
||||
_logger.LogDebug("No token from ITokenProvider for request {Url}", request.RequestUri);
|
||||
}
|
||||
|
||||
var response = await base.SendAsync(request, cancellationToken);
|
||||
|
||||
// 处理认证失败
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
// 2) 检查 401 或 Token-Expired header
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized ||
|
||||
response.Headers.Contains("Token-Expired"))
|
||||
{
|
||||
await HandleUnauthorizedAsync();
|
||||
_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;
|
||||
@@ -53,19 +107,118 @@ namespace Atomx.Admin.Client.Utils
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleUnauthorizedAsync()
|
||||
/// <summary>
|
||||
/// 尝试使用本地保存的 refresh token 调用刷新接口
|
||||
/// API 约定:
|
||||
/// POST /api/sign/refresh
|
||||
/// Body: { token: "...", refreshToken: "..." }
|
||||
/// 返回: AuthResponse { Token, RefreshToken, TokenExpiry }
|
||||
/// </summary>
|
||||
private async Task<bool> TryRefreshTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// 在WASM模式下重定向到登录页
|
||||
if (OperatingSystem.IsBrowser())
|
||||
// 串行化刷新,防止多请求同时触发重复刷新
|
||||
await _refreshLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
_navigationManager.NavigateTo("/account/login", true);
|
||||
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;
|
||||
}
|
||||
}
|
||||
// 在Server模式下可以执行其他操作
|
||||
else
|
||||
finally
|
||||
{
|
||||
// Server端的处理逻辑
|
||||
_logger.LogWarning("Unauthorized access detected in server mode");
|
||||
_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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
using Blazored.LocalStorage;
|
||||
using Atomx.Common.Configuration;
|
||||
using Atomx.Common.Utils;
|
||||
using Atomx.Utils.Extension;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Atomx.Common.Constants;
|
||||
|
||||
namespace Atomx.Admin.Client.Utils
|
||||
{
|
||||
@@ -48,7 +48,7 @@ namespace Atomx.Admin.Client.Utils
|
||||
{
|
||||
try
|
||||
{
|
||||
var jwtToken = await _localStorage.GetItemAsStringAsync(StorageKeys.JWTTokenKeyName);
|
||||
var jwtToken = await _localStorage.GetItemAsStringAsync(StorageKeys.AccessToken);
|
||||
if (string.IsNullOrEmpty(jwtToken))
|
||||
return await Task.FromResult(new AuthenticationState(anonymous));
|
||||
else
|
||||
@@ -128,8 +128,8 @@ namespace Atomx.Admin.Client.Utils
|
||||
|
||||
public async Task MarkUserAsLoggedOut()
|
||||
{
|
||||
await _localStorage.RemoveItemAsync(StorageKeys.JWTTokenKeyName);
|
||||
await _localStorage.RemoveItemAsync(StorageKeys.RefreshTokenKeyName);
|
||||
await _localStorage.RemoveItemAsync(StorageKeys.AccessToken);
|
||||
await _localStorage.RemoveItemAsync(StorageKeys.RefreshToken);
|
||||
|
||||
var authState = Task.FromResult(new AuthenticationState(anonymous));
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Atomx.Common.Configuration;
|
||||
using Atomx.Common.Constants;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace Atomx.Admin.Client.Utils
|
||||
@@ -23,7 +24,7 @@ namespace Atomx.Admin.Client.Utils
|
||||
try
|
||||
{
|
||||
// 从localStorage或sessionStorage获取token
|
||||
return await _jsRuntime.InvokeAsync<string>("localStorage.getItem", StorageKeys.JWTTokenKeyName);
|
||||
return await _jsRuntime.InvokeAsync<string>("localStorage.getItem", StorageKeys.AccessToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
@using Atomx.Common.Enums
|
||||
@using Atomx.Common.Models
|
||||
@using Atomx.Common.Entities
|
||||
@using Atomx.Common.Constant
|
||||
@using Atomx.Common.Constants
|
||||
@using Atomx.Utils.Extension
|
||||
@using Atomx.Utils.Json
|
||||
@using AntDesign
|
||||
|
||||
Reference in New Issue
Block a user