fix auth
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using System.Security.Claims
|
||||||
@inject IPermissionService PermissionService
|
@inject IPermissionService PermissionService
|
||||||
@inject IAuthorizationService AuthorizationService
|
@inject IAuthorizationService AuthorizationService
|
||||||
|
|
||||||
@@ -24,6 +25,8 @@
|
|||||||
</CascadingAuthenticationState>
|
</CascadingAuthenticationState>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
[CascadingParameter] Task<AuthenticationState>? AuthenticationStateTask { get; set; }
|
||||||
|
|
||||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
[Parameter] public string? Permission { get; set; }
|
[Parameter] public string? Permission { get; set; }
|
||||||
[Parameter] public string[]? Permissions { get; set; }
|
[Parameter] public string[]? Permissions { get; set; }
|
||||||
@@ -36,25 +39,83 @@
|
|||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(Policy))
|
_hasPermission = false;
|
||||||
|
|
||||||
|
var authState = AuthenticationStateTask is null
|
||||||
|
? await Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())))
|
||||||
|
: await AuthenticationStateTask;
|
||||||
|
|
||||||
|
var user = authState.User;
|
||||||
|
|
||||||
|
if (user?.Identity is null || !user.Identity.IsAuthenticated)
|
||||||
{
|
{
|
||||||
var authState = await AuthorizationService.AuthorizeAsync(null, Policy);
|
_hasPermission = false;
|
||||||
_hasPermission = authState.Succeeded;
|
return;
|
||||||
}
|
}
|
||||||
else if (!string.IsNullOrEmpty(Permission))
|
|
||||||
|
// 优先基于声明快速判断(适用于 Server 与 WASM)
|
||||||
|
if (!string.IsNullOrEmpty(Permission))
|
||||||
{
|
{
|
||||||
_hasPermission = await PermissionService.HasPermissionAsync(Permission);
|
if (user.Claims.Any(c => c.Type == ClaimKeys.Permission && c.Value == Permission))
|
||||||
|
{
|
||||||
|
_hasPermission = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:调用后端权限服务(适用于 Server-side 权限来源于数据库)
|
||||||
|
_hasPermission = await SafeHasPermissionAsync(Permission);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
else if (Permissions != null && Permissions.Length > 0)
|
|
||||||
|
if (Permissions != null && Permissions.Length > 0)
|
||||||
{
|
{
|
||||||
|
var userPermissions = user.Claims.Where(c => c.Type == ClaimKeys.Permission).Select(c => c.Value).ToHashSet();
|
||||||
|
|
||||||
if (RequireAll)
|
if (RequireAll)
|
||||||
{
|
{
|
||||||
_hasPermission = await PermissionService.HasAllPermissionsAsync(Permissions);
|
if (Permissions.All(p => userPermissions.Contains(p)))
|
||||||
|
{
|
||||||
|
_hasPermission = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_hasPermission = await PermissionService.HasAnyPermissionAsync(Permissions);
|
if (Permissions.Any(p => userPermissions.Contains(p)))
|
||||||
|
{
|
||||||
|
_hasPermission = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 回退:调用后端权限服务
|
||||||
|
if (RequireAll)
|
||||||
|
_hasPermission = await PermissionService.HasAllPermissionsAsync(Permissions);
|
||||||
|
else
|
||||||
|
_hasPermission = await PermissionService.HasAnyPermissionAsync(Permissions);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(Policy))
|
||||||
|
{
|
||||||
|
// 使用 AuthorizationService 并传入当前用户
|
||||||
|
var result = await AuthorizationService.AuthorizeAsync(user, Policy);
|
||||||
|
_hasPermission = result.Succeeded;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> SafeHasPermissionAsync(string permission)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await PermissionService.HasPermissionAsync(permission);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 出错时默认拒绝
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
@page "/account/login"
|
@page "/account/login"
|
||||||
|
@using System.Text.Json
|
||||||
@layout EmptyLayout
|
@layout EmptyLayout
|
||||||
@inject ILogger<Login> Logger
|
@inject ILogger<Login> Logger
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
<PageTitle>登录</PageTitle>
|
<PageTitle>登录</PageTitle>
|
||||||
|
|
||||||
@@ -48,7 +50,6 @@ else
|
|||||||
</Flex>
|
</Flex>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
string handler = "Server";
|
string handler = "Server";
|
||||||
|
|
||||||
@@ -104,30 +105,53 @@ else
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 请求后端登录接口,后端返回 ApiResult<AuthResponse>
|
|
||||||
var api = "/api/sign/in";
|
var api = "/api/sign/in";
|
||||||
var result = await HttpService.Post<ApiResult<AuthResponse>>(api, login);
|
|
||||||
if (result.Success && result.Data != null)
|
if (!OperatingSystem.IsBrowser())
|
||||||
{
|
{
|
||||||
var auth = result.Data;
|
// Server 模式:使用浏览器发起的 fetch(通过 JS)并携带 credentials: 'include'
|
||||||
|
Logger.LogInformation("Server 模式,使用浏览器 fetch 登录");
|
||||||
|
var jsResult = await JS.InvokeAsync<JsonElement>("__atomx_post_json", api, login);
|
||||||
|
|
||||||
// 保存 access + refresh 到 localStorage(WASM 场景)
|
var success = jsResult.TryGetProperty("success", out var sprop) && sprop.GetBoolean();
|
||||||
await localStorage.SetItemAsync("accessToken", auth.Token);
|
if (success && jsResult.TryGetProperty("data", out var dprop) && dprop.ValueKind == JsonValueKind.Object)
|
||||||
await localStorage.SetItemAsync("refreshToken", auth.RefreshToken);
|
|
||||||
|
|
||||||
// 更新客户端 AuthenticationState(调用自定义 Provider 更新方法)
|
|
||||||
if (AuthStateProvider is PersistentAuthenticationStateProvider provider)
|
|
||||||
{
|
{
|
||||||
// provider 仅需要 access token 更新来触发 UI 更新
|
var token = dprop.TryGetProperty("token", out var t) ? t.GetString() ?? string.Empty : string.Empty;
|
||||||
provider.UpdateAuthenticationState(auth.Token);
|
var refresh = dprop.TryGetProperty("refreshToken", out var r) ? r.GetString() ?? string.Empty : string.Empty;
|
||||||
}
|
|
||||||
|
|
||||||
Logger.LogInformation("登录成功,跳转: {ReturnUrl}", ReturnUrl);
|
// WASM 的 localStorage 在 Server Circuit 中无意义,这里不用写 localStorage。
|
||||||
Navigation.NavigateTo(ReturnUrl ?? "/");
|
// 浏览器已通过 fetch 收到 Set-Cookie;强制重载使 Circuit 使用新 Cookie。
|
||||||
|
Logger.LogInformation("登录成功,server 跳转: {ReturnUrl}", ReturnUrl);
|
||||||
|
Navigation.NavigateTo(ReturnUrl ?? "/", forceLoad: true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var msg = jsResult.TryGetProperty("message", out var m) ? m.GetString() ?? "登录失败" : "登录失败";
|
||||||
|
ModalService.Error(new ConfirmOptions() { Title = "提示", Content = msg });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
ModalService.Error(new ConfirmOptions() { Title = "提示", Content = result.Message });
|
// Wasm 模式:继续使用 HttpService(之前逻辑),保存 localStorage 并更新 AuthStateProvider
|
||||||
|
var result = await HttpService.Post<ApiResult<AuthResponse>>(api, login);
|
||||||
|
if (result.Success && result.Data != null)
|
||||||
|
{
|
||||||
|
var auth = result.Data;
|
||||||
|
await localStorage.SetItemAsync(StorageKeys.AccessToken, auth.Token);
|
||||||
|
await localStorage.SetItemAsync(StorageKeys.RefreshToken, auth.RefreshToken);
|
||||||
|
|
||||||
|
if (AuthStateProvider is PersistentAuthenticationStateProvider provider)
|
||||||
|
{
|
||||||
|
provider.UpdateAuthenticationState(auth.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.LogInformation("登录成功,wasm 跳转: {ReturnUrl}", ReturnUrl);
|
||||||
|
Navigation.NavigateTo(ReturnUrl ?? "/");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ModalService.Error(new ConfirmOptions() { Title = "提示", Content = result.Message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -150,3 +174,25 @@ else
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* 页面内 JS 辅助:用于在 Server 模式下从浏览器发起 POST 并携带凭证,使浏览器接收 Set-Cookie *@
|
||||||
|
<script>
|
||||||
|
window.__atomx_post_json = async function (url, data) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return { success: res.ok, message: text };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, message: err?.toString() ?? 'network error' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -1,11 +1,76 @@
|
|||||||
@page "/logout"
|
@page "/logout"
|
||||||
@layout EmptyLayout
|
@layout EmptyLayout
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
@inject ILogger<Logout> Logger
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
@inject HttpService HttpService
|
||||||
|
@using System.Text.Json
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
await ((PersistentAuthenticationStateProvider)AuthStateProvider).MarkUserAsLoggedOut();
|
if (!firstRender) return;
|
||||||
Navigation.NavigateTo("/account/login");
|
|
||||||
await base.OnAfterRenderAsync(firstRender);
|
try
|
||||||
}
|
{
|
||||||
|
// 如果运行在浏览器 (WASM),直接调用后端 API 并清除 localStorage / provider
|
||||||
|
if (OperatingSystem.IsBrowser())
|
||||||
|
{
|
||||||
|
Logger.LogInformation("WASM logout: call API and clear local storage");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await HttpService.Post<ApiResult<string>>("/api/sign/out", null);
|
||||||
|
}
|
||||||
|
catch { /* 忽略网络错误,仍继续清理客户端状态 */ }
|
||||||
|
|
||||||
|
if (AuthStateProvider is Atomx.Admin.Client.Utils.PersistentAuthenticationStateProvider provider)
|
||||||
|
{
|
||||||
|
await provider.MarkUserAsLoggedOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigation.NavigateTo("/account/login");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Server 模式:通过浏览器 fetch 发起带凭据的请求以便浏览器接收并删除 Cookie,然后强制重载
|
||||||
|
Logger.LogInformation("Server logout: use browser fetch to call /api/sign/out");
|
||||||
|
var jsResult = await JS.InvokeAsync<JsonElement>("__atomx_post_json", "/api/sign/out", (object?)null);
|
||||||
|
|
||||||
|
// 尝试解析返回,忽略细节
|
||||||
|
var success = jsResult.ValueKind == JsonValueKind.Object && jsResult.TryGetProperty("success", out var sp) && sp.GetBoolean();
|
||||||
|
Logger.LogInformation("Server logout result: {Success}", success);
|
||||||
|
|
||||||
|
// 尽管我们可能已经处理了服务器态,强制重新加载确保 Circuit 更新
|
||||||
|
Navigation.NavigateTo("/account/login", forceLoad: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(ex, "Logout failed but proceeding to login page");
|
||||||
|
Navigation.NavigateTo("/account/login", forceLoad: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* 页面内 JS 辅助:用于在 Server 模式下从浏览器发起 POST 并携带凭证,使浏览器接收 Set-Cookie/删除 Cookie *@
|
||||||
|
<script>
|
||||||
|
window.__atomx_post_json = async function (url, data) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: data ? JSON.stringify(data) : null
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return { success: res.ok, message: text };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, message: err?.toString() ?? 'network error' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
帐号列表
|
帐号列表
|
||||||
<div>
|
<div>
|
||||||
<AuthorizePermissionView Permission="@Permissions.User.Create">
|
<AuthorizePermissionView Permission="@Permissions.User.Create">
|
||||||
<button class="btn btn-primary">创建用户</button>
|
<Button Class="me-3" OnClick="OnCreateClick" Type="ButtonType.Primary">新增</Button>
|
||||||
</AuthorizePermissionView>
|
</AuthorizePermissionView>
|
||||||
@* <AuthorizeView Policy="@Permissions.Admin.Edit">
|
@* <AuthorizeView Policy="@Permissions.Admin.Edit">
|
||||||
<Authorized>
|
<Authorized>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Atomx.Admin.Client.Utils;
|
|||||||
using Blazored.LocalStorage;
|
using Blazored.LocalStorage;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||||
|
|
||||||
@@ -40,8 +41,13 @@ builder.Services.AddHttpClient("RefreshClient", client =>
|
|||||||
// Ĭ<><C4AC> HttpClient<6E><74><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ע<EFBFBD><D7A2> HttpClient<6E><74>
|
// Ĭ<><C4AC> HttpClient<6E><74><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ע<EFBFBD><D7A2> HttpClient<6E><74>
|
||||||
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("ApiClient"));
|
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("ApiClient"));
|
||||||
|
|
||||||
// <20><> WASM DI <EFBFBD><EFBFBD>ע<EFBFBD><EFBFBD> HttpService<63><65>ʹ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ע<EFBFBD><EFBFBD><EFBFBD><EFBFBD> HttpClient ʵ<EFBFBD><EFBFBD>
|
// <20><> WASM <EFBFBD><EFBFBD> Program.cs<63><73><EFBFBD>ͻ<EFBFBD><CDBB>ˣ<EFBFBD><EFBFBD><EFBFBD>ע<EFBFBD><EFBFBD> HttpService ʱ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ܴ<EFBFBD><EFBFBD>ڵ<EFBFBD> IHttpContextAccessor<EFBFBD><EFBFBD>Server <20>ṩ<EFBFBD><E1B9A9>WASM Ϊ null<EFBFBD><EFBFBD>
|
||||||
builder.Services.AddScoped<HttpService>(sp => new HttpService(sp.GetRequiredService<HttpClient>()));
|
builder.Services.AddScoped<HttpService>(sp =>
|
||||||
|
{
|
||||||
|
var httpClient = sp.GetRequiredService<HttpClient>();
|
||||||
|
var httpContextAccessor = sp.GetService<IHttpContextAccessor>();
|
||||||
|
return new HttpService(httpClient, httpContextAccessor);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Services.AddAntDesign();
|
builder.Services.AddAntDesign();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@code {
|
@code {
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
|
Console.WriteLine("blazor跳转登录页");
|
||||||
Navigation.NavigateTo($"/account/login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true);
|
Navigation.NavigateTo($"/account/login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,26 @@
|
|||||||
using Atomx.Utils.Json;
|
using Atomx.Utils.Json;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
namespace Atomx.Admin.Client.Services
|
namespace Atomx.Admin.Client.Services
|
||||||
{
|
{
|
||||||
public class HttpService(HttpClient httpClient)
|
public class HttpService
|
||||||
{
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly IHttpContextAccessor? _httpContextAccessor;
|
||||||
|
|
||||||
|
public HttpService(HttpClient httpClient, IHttpContextAccessor? httpContextAccessor = null)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_httpContextAccessor = httpContextAccessor;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<T> Get<T>(string url)
|
public async Task<T> Get<T>(string url)
|
||||||
{
|
{
|
||||||
var response = await httpClient.GetAsync(url);
|
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
AttachCookieIfServer(request);
|
||||||
|
var response = await _httpClient.SendAsync(request);
|
||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var content = await response.Content.ReadAsStringAsync();
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
@@ -24,8 +36,13 @@ namespace Atomx.Admin.Client.Services
|
|||||||
public async Task<T> Post<T>(string url, object data)
|
public async Task<T> Post<T>(string url, object data)
|
||||||
{
|
{
|
||||||
var json = data.ToJson();
|
var json = data.ToJson();
|
||||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||||
var response = await httpClient.PostAsync(url, content);
|
{
|
||||||
|
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||||
|
};
|
||||||
|
AttachCookieIfServer(request);
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request);
|
||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var responseContent = await response.Content.ReadAsStringAsync();
|
var responseContent = await response.Content.ReadAsStringAsync();
|
||||||
@@ -46,7 +63,14 @@ namespace Atomx.Admin.Client.Services
|
|||||||
page = 1;
|
page = 1;
|
||||||
}
|
}
|
||||||
url = $"{url}?page={page}&size={size}";
|
url = $"{url}?page={page}&size={size}";
|
||||||
var response = await httpClient.PostAsJsonAsync(url, data);
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(data)
|
||||||
|
};
|
||||||
|
AttachCookieIfServer(request);
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request);
|
||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var content = await response.Content.ReadAsStringAsync();
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
@@ -54,6 +78,7 @@ namespace Atomx.Admin.Client.Services
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
// 明确抛 Unauthorized 以便上层按需处理
|
||||||
throw new Exception($"Error: {response.StatusCode}");
|
throw new Exception($"Error: {response.StatusCode}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,5 +88,32 @@ namespace Atomx.Admin.Client.Services
|
|||||||
throw new Exception($"api {url} service failure");
|
throw new Exception($"api {url} service failure");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 如果在 Server 环境并且 IHttpContextAccessor 可用,则把浏览器请求的 Cookie 转发到后端请求中
|
||||||
|
/// </summary>
|
||||||
|
private void AttachCookieIfServer(HttpRequestMessage request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsBrowser())
|
||||||
|
{
|
||||||
|
var ctx = _httpContextAccessor?.HttpContext;
|
||||||
|
if (ctx != null && ctx.Request.Headers.TryGetValue("Cookie", out var cookie) && !string.IsNullOrEmpty(cookie))
|
||||||
|
{
|
||||||
|
// 覆盖或添加 Cookie header
|
||||||
|
if (request.Headers.Contains("Cookie"))
|
||||||
|
{
|
||||||
|
request.Headers.Remove("Cookie");
|
||||||
|
}
|
||||||
|
request.Headers.Add("Cookie", (string)cookie);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略任何转发异常,保持健壮性
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
using Blazored.LocalStorage;
|
using Atomx.Common.Constants;
|
||||||
using Atomx.Common.Configuration;
|
|
||||||
using Atomx.Utils.Extension;
|
using Atomx.Utils.Extension;
|
||||||
|
using Blazored.LocalStorage;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Atomx.Common.Constants;
|
|
||||||
|
|
||||||
namespace Atomx.Admin.Client.Utils
|
namespace Atomx.Admin.Client.Utils
|
||||||
{
|
{
|
||||||
@@ -13,59 +12,62 @@ namespace Atomx.Admin.Client.Utils
|
|||||||
{
|
{
|
||||||
readonly ClaimsPrincipal anonymous = new(new ClaimsIdentity());
|
readonly ClaimsPrincipal anonymous = new(new ClaimsIdentity());
|
||||||
|
|
||||||
static readonly Task<AuthenticationState> defaultUnauthenticatedTask = Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())));
|
// 如果运行在 Server 且在 prerender 时有 Persisted UserInfo,则存储预设的 AuthenticationState
|
||||||
readonly Task<AuthenticationState> authenticationStateTask = defaultUnauthenticatedTask;
|
private Task<AuthenticationState>? _preRenderedAuthState;
|
||||||
|
|
||||||
readonly ILocalStorageService _localStorage;
|
readonly ILocalStorageService _localStorage;
|
||||||
|
|
||||||
public PersistentAuthenticationStateProvider(PersistentComponentState state, ILocalStorageService localStorageService)
|
public PersistentAuthenticationStateProvider(IServiceProvider serviceProvider, ILocalStorageService localStorageService)
|
||||||
{
|
{
|
||||||
_localStorage = localStorageService;
|
_localStorage = localStorageService;
|
||||||
|
|
||||||
if (!state.TryTakeFromJson<UserInfo>(nameof(UserInfo), out var userInfo) || userInfo is null)
|
// 尝试有条件解析 PersistedComponentState(仅在 Server 交互渲染时可用)
|
||||||
|
var state = serviceProvider.GetService<PersistentComponentState>();
|
||||||
|
if (state != null)
|
||||||
{
|
{
|
||||||
return;
|
if (state.TryTakeFromJson<UserInfo>(nameof(UserInfo), out var userInfo) && userInfo is not null)
|
||||||
}
|
|
||||||
|
|
||||||
var claims = new List<Claim>
|
|
||||||
{
|
{
|
||||||
new(ClaimKeys.Id, userInfo.Id.ToString()),
|
var claims = new List<Claim>
|
||||||
new(ClaimKeys.Name, userInfo.Name),
|
{
|
||||||
new(ClaimKeys.Email, userInfo.Email),
|
new(ClaimKeys.Id, userInfo.Id.ToString()),
|
||||||
new(ClaimKeys.Mobile, userInfo.MobilePhone),
|
new(ClaimKeys.Name, userInfo.Name),
|
||||||
new(ClaimKeys.Role, userInfo.Role),
|
new(ClaimKeys.Email, userInfo.Email),
|
||||||
};
|
new(ClaimKeys.Mobile, userInfo.MobilePhone),
|
||||||
foreach (var role in userInfo.Permissions)
|
new(ClaimKeys.Role, userInfo.Role),
|
||||||
{
|
};
|
||||||
claims.Add(new Claim(ClaimKeys.Permission, role));
|
foreach (var role in userInfo.Permissions ?? Array.Empty<string>())
|
||||||
}
|
{
|
||||||
|
claims.Add(new Claim(ClaimKeys.Permission, role));
|
||||||
|
}
|
||||||
|
|
||||||
authenticationStateTask = Task.FromResult(
|
var cp = new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: nameof(PersistentAuthenticationStateProvider)));
|
||||||
new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: nameof(PersistentAuthenticationStateProvider)))));
|
_preRenderedAuthState = Task.FromResult(new AuthenticationState(cp));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||||
{
|
{
|
||||||
|
// 如果在 prerender 阶段已从 PersistentComponentState 恢复用户,优先返回该状态(Server prerender)
|
||||||
|
if (_preRenderedAuthState != null)
|
||||||
|
return await _preRenderedAuthState;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var jwtToken = await _localStorage.GetItemAsStringAsync(StorageKeys.AccessToken);
|
var jwtToken = await _localStorage.GetItemAsStringAsync(StorageKeys.AccessToken);
|
||||||
if (string.IsNullOrEmpty(jwtToken))
|
if (string.IsNullOrEmpty(jwtToken))
|
||||||
return await Task.FromResult(new AuthenticationState(anonymous));
|
return new AuthenticationState(anonymous);
|
||||||
else
|
|
||||||
{
|
var getUserClaims = DecryptToken(jwtToken);
|
||||||
var getUserClaims = DecryptToken(jwtToken);
|
if (getUserClaims == null || string.IsNullOrEmpty(getUserClaims.Name))
|
||||||
if (getUserClaims == null)
|
return new AuthenticationState(anonymous);
|
||||||
return await Task.FromResult(new AuthenticationState(anonymous));
|
|
||||||
else
|
var claimsPrincipal = SetClaimPrincipal(getUserClaims);
|
||||||
{
|
return new AuthenticationState(claimsPrincipal);
|
||||||
var claimsPrincipal = SetClaimPrincipal(getUserClaims);
|
|
||||||
return await Task.FromResult(new AuthenticationState(claimsPrincipal));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return await Task.FromResult(new AuthenticationState(anonymous));
|
return new AuthenticationState(anonymous);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,13 +85,12 @@ namespace Atomx.Admin.Client.Utils
|
|||||||
new(ClaimKeys.Mobile, customUserClaims.MobilePhone),
|
new(ClaimKeys.Mobile, customUserClaims.MobilePhone),
|
||||||
new(ClaimKeys.Role, customUserClaims.Role.ToString()),
|
new(ClaimKeys.Role, customUserClaims.Role.ToString()),
|
||||||
};
|
};
|
||||||
foreach (var role in customUserClaims.Permissions)
|
foreach (var role in customUserClaims.Permissions ?? Array.Empty<string>())
|
||||||
{
|
{
|
||||||
claims.Add(new Claim(ClaimKeys.Permission, role));
|
claims.Add(new Claim(ClaimKeys.Permission, role));
|
||||||
}
|
}
|
||||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: nameof(PersistentAuthenticationStateProvider)));
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: nameof(PersistentAuthenticationStateProvider)));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UpdateAuthenticationState(string jwtToken = "")
|
public void UpdateAuthenticationState(string jwtToken = "")
|
||||||
@@ -113,8 +114,6 @@ namespace Atomx.Admin.Client.Utils
|
|||||||
var handler = new JwtSecurityTokenHandler();
|
var handler = new JwtSecurityTokenHandler();
|
||||||
var token = handler.ReadJwtToken(jwtToken);
|
var token = handler.ReadJwtToken(jwtToken);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
var id = token.Claims.SingleOrDefault(x => x.Type == ClaimKeys.Id)?.Value ?? string.Empty;
|
var id = token.Claims.SingleOrDefault(x => x.Type == ClaimKeys.Id)?.Value ?? string.Empty;
|
||||||
var name = token.Claims.SingleOrDefault(x => x.Type == ClaimKeys.Name)?.Value ?? string.Empty;
|
var name = token.Claims.SingleOrDefault(x => x.Type == ClaimKeys.Name)?.Value ?? string.Empty;
|
||||||
var email = token.Claims.SingleOrDefault(x => x.Type == ClaimKeys.Email)?.Value ?? string.Empty;
|
var email = token.Claims.SingleOrDefault(x => x.Type == ClaimKeys.Email)?.Value ?? string.Empty;
|
||||||
@@ -132,7 +131,6 @@ namespace Atomx.Admin.Client.Utils
|
|||||||
await _localStorage.RemoveItemAsync(StorageKeys.RefreshToken);
|
await _localStorage.RemoveItemAsync(StorageKeys.RefreshToken);
|
||||||
|
|
||||||
var authState = Task.FromResult(new AuthenticationState(anonymous));
|
var authState = Task.FromResult(new AuthenticationState(anonymous));
|
||||||
|
|
||||||
NotifyAuthenticationStateChanged(authState);
|
NotifyAuthenticationStateChanged(authState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
|
using Atomx.Admin.Client.Models;
|
||||||
using AntDesign;
|
|
||||||
using Atomx.Admin.Client.Models;
|
|
||||||
using Atomx.Admin.Client.Validators;
|
using Atomx.Admin.Client.Validators;
|
||||||
using Atomx.Admin.Services;
|
using Atomx.Admin.Services;
|
||||||
using Atomx.Common.Constants;
|
using Atomx.Common.Constants;
|
||||||
@@ -9,16 +7,16 @@ using Atomx.Common.Models;
|
|||||||
using Atomx.Data;
|
using Atomx.Data;
|
||||||
using Atomx.Data.CacheServices;
|
using Atomx.Data.CacheServices;
|
||||||
using Atomx.Data.Services;
|
using Atomx.Data.Services;
|
||||||
using Atomx.Utils.Json;
|
|
||||||
using MapsterMapper;
|
using MapsterMapper;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace Atomx.Admin.Controllers
|
namespace Atomx.Admin.Controllers
|
||||||
{
|
{
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
public class RoleController : ControllerBase
|
public class RoleController : ControllerBase
|
||||||
{
|
{
|
||||||
readonly ILogger<RoleController> _logger;
|
readonly ILogger<RoleController> _logger;
|
||||||
|
|||||||
@@ -191,13 +191,14 @@ namespace Atomx.Admin.Controllers
|
|||||||
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes)
|
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes)
|
||||||
});
|
});
|
||||||
|
|
||||||
// 设置Cookie(用于Server模式)
|
// 设置Cookie(用于Server模式)—— 明确指定与 Logout 同样的属性,便于删除
|
||||||
var cookieOptions = new CookieOptions
|
var cookieOptions = new CookieOptions
|
||||||
{
|
{
|
||||||
HttpOnly = true,
|
HttpOnly = true,
|
||||||
Expires = DateTime.UtcNow.AddDays(7),
|
Expires = DateTime.UtcNow.AddDays(7),
|
||||||
SameSite = SameSiteMode.Strict,
|
SameSite = SameSiteMode.Lax,
|
||||||
Secure = Request.IsHttps
|
Secure = Request.IsHttps,
|
||||||
|
Path = "/"
|
||||||
};
|
};
|
||||||
|
|
||||||
Response.Cookies.Append("accessToken", accessToken, cookieOptions);
|
Response.Cookies.Append("accessToken", accessToken, cookieOptions);
|
||||||
@@ -318,8 +319,9 @@ namespace Atomx.Admin.Controllers
|
|||||||
{
|
{
|
||||||
HttpOnly = true,
|
HttpOnly = true,
|
||||||
Expires = DateTime.UtcNow.AddDays(7),
|
Expires = DateTime.UtcNow.AddDays(7),
|
||||||
SameSite = SameSiteMode.Strict,
|
SameSite = SameSiteMode.Lax,
|
||||||
Secure = Request.IsHttps
|
Secure = Request.IsHttps,
|
||||||
|
Path = "/"
|
||||||
};
|
};
|
||||||
|
|
||||||
Response.Cookies.Append("accessToken", accessToken, cookieOptions);
|
Response.Cookies.Append("accessToken", accessToken, cookieOptions);
|
||||||
@@ -374,11 +376,28 @@ namespace Atomx.Admin.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理 Cookie
|
try
|
||||||
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
{
|
||||||
|
// 清理 Cookie(SignOutAsync 清除认证会话)
|
||||||
|
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
|
||||||
Response.Cookies.Delete("accessToken");
|
// 显式覆写并过期曾设置的 token cookies,确保浏览器删除(与 Login 写入时使用的选项一致)
|
||||||
Response.Cookies.Delete("refreshToken");
|
var expiredOptions = new CookieOptions
|
||||||
|
{
|
||||||
|
HttpOnly = true,
|
||||||
|
Expires = DateTime.UtcNow.AddDays(-1),
|
||||||
|
SameSite = SameSiteMode.Lax,
|
||||||
|
Secure = Request.IsHttps,
|
||||||
|
Path = "/"
|
||||||
|
};
|
||||||
|
|
||||||
|
Response.Cookies.Append("accessToken", string.Empty, expiredOptions);
|
||||||
|
Response.Cookies.Append("refreshToken", string.Empty, expiredOptions);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "登出时清除 Cookie 失败(允许)");
|
||||||
|
}
|
||||||
|
|
||||||
return new JsonResult(new ApiResult<string>().IsSuccess("已退出"));
|
return new JsonResult(new ApiResult<string>().IsSuccess("已退出"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
using Atomx.Admin.Utils;
|
using Atomx.Admin.Utils;
|
||||||
using Atomx.Common.Constants;
|
using Atomx.Common.Constants;
|
||||||
using Atomx.Common.Models;
|
using Atomx.Common.Models;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace Atomx.Admin.Extensions
|
namespace Atomx.Admin.Extensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// AuthorizationExtension 重构说明:
|
/// AuthorizationExtension 重构说明:
|
||||||
/// - 配置 Cookie + JwtBearer 双方案,JwtBearer 的 Events 中添加对 SignalR / WebSocket 的 query string access_token 读取(OnMessageReceived)
|
/// - 为混合部署(Blazor Server + WASM)与跨站点场景优化 Challenge 行为:
|
||||||
/// - 保持 OnChallenge 的重定向行为(对于浏览器访问 API 时友好)
|
/// * 对浏览器导航/页面请求执行重定向到登录页(保持友好体验)
|
||||||
/// - 将 JwtSetting 注入为 Singleton 供 TokenService 使用
|
/// * 对 API / XHR / Fetch / SignalR 请求返回 401 JSON(避免 HTML 重定向导致前端错误)
|
||||||
|
/// - 保持 JwtBearer 对 SignalR query string 的读取
|
||||||
|
/// - Cookie 的 SecurePolicy 根据环境设置,SameSite 使用 Lax(可在跨站点场景改为 None 并开启 AllowCredentials)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class AuthorizationExtension
|
public static class AuthorizationExtension
|
||||||
{
|
{
|
||||||
@@ -28,10 +29,12 @@ namespace Atomx.Admin.Extensions
|
|||||||
|
|
||||||
services.AddAuthentication(options =>
|
services.AddAuthentication(options =>
|
||||||
{
|
{
|
||||||
// 默认用于 API 的认证方案为 JwtBearer
|
// 为混合 Server + API 场景选择:默认用于认证/读取身份的方案为 Cookie(Server 端 Circuit 使用)
|
||||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||||
|
// 当需要挑战(Challenge)时使用 JwtBearer 的行为(其 OnChallenge 可做重定向处理)
|
||||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||||
})
|
})
|
||||||
|
// JwtBearer 保持用于 API token 校验
|
||||||
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
|
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
|
||||||
{
|
{
|
||||||
options.RequireHttpsMetadata = !environment.IsDevelopment();
|
options.RequireHttpsMetadata = !environment.IsDevelopment();
|
||||||
@@ -55,7 +58,6 @@ namespace Atomx.Admin.Extensions
|
|||||||
{
|
{
|
||||||
OnMessageReceived = context =>
|
OnMessageReceived = context =>
|
||||||
{
|
{
|
||||||
// SignalR 客户端常把 token 放在 query string 参数 access_token
|
|
||||||
var accessToken = context.Request.Query["access_token"].FirstOrDefault();
|
var accessToken = context.Request.Query["access_token"].FirstOrDefault();
|
||||||
var path = context.HttpContext.Request.Path;
|
var path = context.HttpContext.Request.Path;
|
||||||
if (!string.IsNullOrEmpty(accessToken) && (path.StartsWithSegments("/hubs") || path.StartsWithSegments("/hub") || path.StartsWithSegments("/api")))
|
if (!string.IsNullOrEmpty(accessToken) && (path.StartsWithSegments("/hubs") || path.StartsWithSegments("/hub") || path.StartsWithSegments("/api")))
|
||||||
@@ -64,13 +66,39 @@ namespace Atomx.Admin.Extensions
|
|||||||
}
|
}
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
},
|
},
|
||||||
OnChallenge = context =>
|
OnChallenge = async context =>
|
||||||
{
|
{
|
||||||
// 浏览器端访问 API 并无 token 时重定向到登录页
|
// 当 JwtChallenge 触发时,根据请求类型决定行为:
|
||||||
var absoluteUri = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}{context.Request.QueryString}";
|
// - 浏览器导航 / Accept:text/html(页面请求) => 重定向到登录页
|
||||||
context.HandleResponse();
|
// - API / XHR / JSON 请求 => 返回 401 JSON
|
||||||
context.Response.Redirect($"/account/login?returnUrl={Uri.EscapeDataString(absoluteUri)}");
|
try
|
||||||
return Task.CompletedTask;
|
{
|
||||||
|
var request = context.Request;
|
||||||
|
var accept = request.Headers["Accept"].FirstOrDefault() ?? string.Empty;
|
||||||
|
var isApiRequest = request.Path.StartsWithSegments("/api") || request.Path.StartsWithSegments("/hubs") || request.Headers["X-Requested-With"] == "XMLHttpRequest";
|
||||||
|
var expectsHtml = accept.Contains("text/html", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
context.HandleResponse();
|
||||||
|
|
||||||
|
if (!isApiRequest && expectsHtml)
|
||||||
|
{
|
||||||
|
var absoluteUri = $"{request.Scheme}://{request.Host}{request.Path}{request.QueryString}";
|
||||||
|
context.Response.Redirect($"/account/login?returnUrl={Uri.EscapeDataString(absoluteUri)}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
context.Response.ContentType = "application/json; charset=utf-8";
|
||||||
|
var payload = new { success = false, message = "Unauthorized" };
|
||||||
|
await context.Response.WriteAsJsonAsync(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 兜底:返回 401
|
||||||
|
context.HandleResponse();
|
||||||
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
OnAuthenticationFailed = context =>
|
OnAuthenticationFailed = context =>
|
||||||
{
|
{
|
||||||
@@ -81,7 +109,32 @@ namespace Atomx.Admin.Extensions
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}).AddCookie();
|
})
|
||||||
|
// 明确配置 Cookie,用于 Server 交互渲染(Circuit 使用 Cookie 进行认证)
|
||||||
|
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
|
||||||
|
{
|
||||||
|
// LoginPath/AccessDeniedPath 可根据需要指定路由
|
||||||
|
options.LoginPath = "/account/login";
|
||||||
|
options.AccessDeniedPath = "/account/login";
|
||||||
|
|
||||||
|
// Cookie 安全策略:HttpOnly + SameSite = Lax(避免 Strict 在重定向/导航时不发送)
|
||||||
|
options.Cookie.HttpOnly = true;
|
||||||
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
|
|
||||||
|
// 根据环境调整 SecurePolicy,生产环境强制 HTTPS
|
||||||
|
options.Cookie.SecurePolicy = environment.IsDevelopment()
|
||||||
|
? CookieSecurePolicy.SameAsRequest
|
||||||
|
: CookieSecurePolicy.Always;
|
||||||
|
|
||||||
|
options.Cookie.Path = "/";
|
||||||
|
|
||||||
|
// 当使用 Blazor Server 时,Cookie 名称可以指定(可选)
|
||||||
|
// options.Cookie.Name = "AtomxAuth";
|
||||||
|
|
||||||
|
// 控制到期与滑动过期
|
||||||
|
options.ExpireTimeSpan = TimeSpan.FromDays(7);
|
||||||
|
options.SlidingExpiration = true;
|
||||||
|
});
|
||||||
|
|
||||||
// 注册基于权限的策略
|
// 注册基于权限的策略
|
||||||
services.AddAuthorization(options =>
|
services.AddAuthorization(options =>
|
||||||
|
|||||||
@@ -84,9 +84,7 @@ builder.Services.AddAuthorize(builder.Configuration, builder.Environment); //
|
|||||||
var connection = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
|
var connection = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
|
||||||
builder.Services.AddDbContext<DataContext>(options => options.UseNpgsql(connection, p => p.MigrationsHistoryTable("__DbMigrationsHistory")));
|
builder.Services.AddDbContext<DataContext>(options => options.UseNpgsql(connection, p => p.MigrationsHistoryTable("__DbMigrationsHistory")));
|
||||||
|
|
||||||
// Redis <20><><EFBFBD>棨<EFBFBD>Ѵ<EFBFBD><EFBFBD>ڣ<EFBFBD>
|
// Redis <20><><EFBFBD><EFBFBD>
|
||||||
// ... <20><><EFBFBD><EFBFBD>ԭ<EFBFBD><D4AD> Redis ע<><D7A2>
|
|
||||||
|
|
||||||
var redisConnection = builder.Configuration.GetConnectionString("cache");
|
var redisConnection = builder.Configuration.GetConnectionString("cache");
|
||||||
builder.Services.AddStackExchangeRedisCache(options =>
|
builder.Services.AddStackExchangeRedisCache(options =>
|
||||||
{
|
{
|
||||||
@@ -105,6 +103,37 @@ builder.Services.AddResponseCompression(options =>
|
|||||||
.ToArray();
|
.ToArray();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// CORS<52><53><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ҫ<EFBFBD><D2AA><EFBFBD><EFBFBD>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Դ<EFBFBD><D4B4>֧<EFBFBD><D6A7> Cookie<69><65>AllowCredentials<6C><73>
|
||||||
|
var corsOrigins = builder.Configuration["Cors:AllowedOrigins"];
|
||||||
|
if (!string.IsNullOrEmpty(corsOrigins))
|
||||||
|
{
|
||||||
|
var origins = corsOrigins.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray();
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy("DefaultCors", policy =>
|
||||||
|
{
|
||||||
|
policy.WithOrigins(origins)
|
||||||
|
.AllowAnyMethod()
|
||||||
|
.AllowAnyHeader()
|
||||||
|
.AllowCredentials();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// <20><><EFBFBD><EFBFBD>ʱ<EFBFBD><CAB1><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> origin<69><6E><EFBFBD><EFBFBD><EFBFBD>Ƽ<EFBFBD><C6BC><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy("DefaultCors", policy =>
|
||||||
|
{
|
||||||
|
policy.AllowAnyOrigin()
|
||||||
|
.AllowAnyMethod()
|
||||||
|
.AllowAnyHeader();
|
||||||
|
// ע<>⣺AllowAnyOrigin <20><> AllowCredentials <20><><EFBFBD><EFBFBD>ͬʱʹ<CAB1><CAB9>
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
builder.Services.AddAntDesign();
|
builder.Services.AddAntDesign();
|
||||||
builder.Services.Configure<MonitoringOptions>(builder.Configuration.GetSection("Monitoring"));
|
builder.Services.Configure<MonitoringOptions>(builder.Configuration.GetSection("Monitoring"));
|
||||||
@@ -132,13 +161,8 @@ else
|
|||||||
|
|
||||||
app.UseResponseCompression();
|
app.UseResponseCompression();
|
||||||
|
|
||||||
app.UseCors(option =>
|
// ʹ<><CAB9><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> CORS <20><><EFBFBD><EFBFBD>
|
||||||
{
|
app.UseCors("DefaultCors");
|
||||||
// ע<>⣺<EFBFBD><E2A3BA><EFBFBD><EFBFBD>ʹ<EFBFBD><CAB9> Cookie <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ҫ AllowCredentials <20><>ָ<EFBFBD><D6B8><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Դ<EFBFBD><D4B4><EFBFBD>˴<EFBFBD>Ϊ<EFBFBD><CEAA><EFBFBD><EFBFBD>ʾ<EFBFBD><CABE><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Դ<EFBFBD><D4B4><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ӧ<EFBFBD><D3A6><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
|
||||||
option.AllowAnyOrigin();
|
|
||||||
option.AllowAnyMethod();
|
|
||||||
option.AllowAnyHeader();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.UseStaticFiles(new StaticFileOptions
|
app.UseStaticFiles(new StaticFileOptions
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ namespace Atomx.Admin.Utils
|
|||||||
private readonly PersistentComponentState _state;
|
private readonly PersistentComponentState _state;
|
||||||
private readonly IdentityOptions _options;
|
private readonly IdentityOptions _options;
|
||||||
|
|
||||||
private readonly PersistingComponentStateSubscription _subscription;
|
private readonly List<PersistingComponentStateSubscription> _subscriptions = new();
|
||||||
|
|
||||||
private Task<AuthenticationState>? _authenticationStateTask;
|
private Task<AuthenticationState>? _authenticationStateTask;
|
||||||
|
|
||||||
@@ -33,7 +33,28 @@ namespace Atomx.Admin.Utils
|
|||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
|
|
||||||
AuthenticationStateChanged += OnAuthenticationStateChanged;
|
AuthenticationStateChanged += OnAuthenticationStateChanged;
|
||||||
_subscription = state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveWebAssembly);
|
|
||||||
|
// 注册为在 InteractiveServer 和 InteractiveWebAssembly 场景下持久化认证状态。
|
||||||
|
// 原实现仅在 InteractiveWebAssembly 下注册,导致 InteractiveServer 模式无法持久化认证信息,
|
||||||
|
// 从而出现 Server 模式下不断跳转到登录页的问题。
|
||||||
|
// 为了兼容不同渲染模式,这里为两种 Interactive 渲染模式都注册回调并保存订阅,以便正确释放。
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_subscriptions.Add(_state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveWebAssembly));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 如果特定的 RenderMode 在运行时不可用则忽略异常,仍尽可能注册其它模式。
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_subscriptions.Add(_state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveServer));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略注册异常,防御性编程以适应不同运行时与库版本。
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
|
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
|
||||||
@@ -41,7 +62,9 @@ namespace Atomx.Admin.Utils
|
|||||||
protected override async Task<bool> ValidateAuthenticationStateAsync(
|
protected override async Task<bool> ValidateAuthenticationStateAsync(
|
||||||
AuthenticationState authenticationState, CancellationToken cancellationToken)
|
AuthenticationState authenticationState, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Get the user manager from a new scope to ensure it fetches fresh data
|
// 保持简单的验证逻辑:只在已认证时返回 true。
|
||||||
|
// 如果需要更严格的验证(例如 security stamp、数据库比对)可以在此扩展,
|
||||||
|
// 并使用 _scopeFactory.CreateAsyncScope() 获取作用域服务进行验证。
|
||||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
return ValidateSecurityStampAsync(authenticationState.User);
|
return ValidateSecurityStampAsync(authenticationState.User);
|
||||||
}
|
}
|
||||||
@@ -80,7 +103,7 @@ namespace Atomx.Admin.Utils
|
|||||||
var role = principal.Claims.SingleOrDefault(x => x.Type == ClaimKeys.Role)?.Value ?? string.Empty;
|
var role = principal.Claims.SingleOrDefault(x => x.Type == ClaimKeys.Role)?.Value ?? string.Empty;
|
||||||
var permission = principal.Claims.Where(x => x.Type == ClaimKeys.Permission).Select(s => s.Value).ToArray();
|
var permission = principal.Claims.Where(x => x.Type == ClaimKeys.Permission).Select(s => s.Value).ToArray();
|
||||||
|
|
||||||
if (id != null && name != null)
|
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(name))
|
||||||
{
|
{
|
||||||
_state.PersistAsJson(nameof(UserInfo), new UserInfo
|
_state.PersistAsJson(nameof(UserInfo), new UserInfo
|
||||||
{
|
{
|
||||||
@@ -97,7 +120,18 @@ namespace Atomx.Admin.Utils
|
|||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
_subscription.Dispose();
|
foreach (var sub in _subscriptions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
sub.Dispose();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略单个订阅释放异常,确保其他订阅能够正确释放
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
AuthenticationStateChanged -= OnAuthenticationStateChanged;
|
AuthenticationStateChanged -= OnAuthenticationStateChanged;
|
||||||
base.Dispose(disposing);
|
base.Dispose(disposing);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user