This commit is contained in:
yxw
2025-12-04 19:07:04 +08:00
parent 6217a8ca55
commit bd95848972
13 changed files with 484 additions and 127 deletions

View File

@@ -1,4 +1,5 @@
@using Microsoft.AspNetCore.Authorization
@using System.Security.Claims
@inject IPermissionService PermissionService
@inject IAuthorizationService AuthorizationService
@@ -24,6 +25,8 @@
</CascadingAuthenticationState>
@code {
[CascadingParameter] Task<AuthenticationState>? AuthenticationStateTask { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public string? Permission { get; set; }
[Parameter] public string[]? Permissions { get; set; }
@@ -36,25 +39,83 @@
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 = authState.Succeeded;
_hasPermission = false;
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)
{
_hasPermission = await PermissionService.HasAllPermissionsAsync(Permissions);
if (Permissions.All(p => userPermissions.Contains(p)))
{
_hasPermission = true;
return;
}
}
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;
}
}
}

View File

@@ -1,6 +1,8 @@
@page "/account/login"
@using System.Text.Json
@layout EmptyLayout
@inject ILogger<Login> Logger
@inject IJSRuntime JS
<PageTitle>登录</PageTitle>
@@ -48,7 +50,6 @@ else
</Flex>
}
@code {
string handler = "Server";
@@ -104,30 +105,53 @@ else
try
{
// 请求后端登录接口,后端返回 ApiResult<AuthResponse>
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 到 localStorageWASM 场景)
await localStorage.SetItemAsync("accessToken", auth.Token);
await localStorage.SetItemAsync("refreshToken", auth.RefreshToken);
// 更新客户端 AuthenticationState调用自定义 Provider 更新方法)
if (AuthStateProvider is PersistentAuthenticationStateProvider provider)
var success = jsResult.TryGetProperty("success", out var sprop) && sprop.GetBoolean();
if (success && jsResult.TryGetProperty("data", out var dprop) && dprop.ValueKind == JsonValueKind.Object)
{
// provider 仅需要 access token 更新来触发 UI 更新
provider.UpdateAuthenticationState(auth.Token);
}
var token = dprop.TryGetProperty("token", out var t) ? t.GetString() ?? string.Empty : string.Empty;
var refresh = dprop.TryGetProperty("refreshToken", out var r) ? r.GetString() ?? string.Empty : string.Empty;
Logger.LogInformation("登录成功,跳转: {ReturnUrl}", ReturnUrl);
Navigation.NavigateTo(ReturnUrl ?? "/");
// WASM 的 localStorage 在 Server Circuit 中无意义,这里不用写 localStorage。
// 浏览器已通过 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
{
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)
@@ -149,4 +173,26 @@ else
await LoginAsync();
}
}
}
}
@* 页面内 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>

View File

@@ -1,11 +1,76 @@
@page "/logout"
@layout EmptyLayout
@inject IJSRuntime JS
@inject ILogger<Logout> Logger
@inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthStateProvider
@inject HttpService HttpService
@using System.Text.Json
@code {
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await ((PersistentAuthenticationStateProvider)AuthStateProvider).MarkUserAsLoggedOut();
Navigation.NavigateTo("/account/login");
await base.OnAfterRenderAsync(firstRender);
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
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>

View File

@@ -33,7 +33,7 @@
帐号列表
<div>
<AuthorizePermissionView Permission="@Permissions.User.Create">
<button class="btn btn-primary">创建用户</button>
<Button Class="me-3" OnClick="OnCreateClick" Type="ButtonType.Primary">新增</Button>
</AuthorizePermissionView>
@* <AuthorizeView Policy="@Permissions.Admin.Edit">
<Authorized>

View File

@@ -3,6 +3,7 @@ using Atomx.Admin.Client.Utils;
using Blazored.LocalStorage;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.AspNetCore.Http;
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>
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>
builder.Services.AddScoped<HttpService>(sp => new HttpService(sp.GetRequiredService<HttpClient>()));
// <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 =>
{
var httpClient = sp.GetRequiredService<HttpClient>();
var httpContextAccessor = sp.GetService<IHttpContextAccessor>();
return new HttpService(httpClient, httpContextAccessor);
});
builder.Services.AddAntDesign();

View File

@@ -3,6 +3,7 @@
@code {
protected override void OnInitialized()
{
Console.WriteLine("blazor跳转登录页");
Navigation.NavigateTo($"/account/login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true);
}
}

View File

@@ -2,14 +2,26 @@
using Atomx.Utils.Json;
using System.Net.Http.Json;
using System.Text;
using Microsoft.AspNetCore.Http;
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)
{
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)
{
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)
{
var json = data.ToJson();
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await httpClient.PostAsync(url, content);
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
AttachCookieIfServer(request);
var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var responseContent = await response.Content.ReadAsStringAsync();
@@ -46,7 +63,14 @@ namespace Atomx.Admin.Client.Services
page = 1;
}
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)
{
var content = await response.Content.ReadAsStringAsync();
@@ -54,6 +78,7 @@ namespace Atomx.Admin.Client.Services
}
else
{
// 明确抛 Unauthorized 以便上层按需处理
throw new Exception($"Error: {response.StatusCode}");
}
}
@@ -63,5 +88,32 @@ namespace Atomx.Admin.Client.Services
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
{
// 忽略任何转发异常,保持健壮性
}
}
}
}

View File

@@ -1,11 +1,10 @@
using Blazored.LocalStorage;
using Atomx.Common.Configuration;
using Atomx.Common.Constants;
using Atomx.Utils.Extension;
using Blazored.LocalStorage;
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
{
@@ -13,59 +12,62 @@ namespace Atomx.Admin.Client.Utils
{
readonly ClaimsPrincipal anonymous = new(new ClaimsIdentity());
static readonly Task<AuthenticationState> defaultUnauthenticatedTask = Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())));
readonly Task<AuthenticationState> authenticationStateTask = defaultUnauthenticatedTask;
// 如果运行在 Server 且在 prerender 时有 Persisted UserInfo则存储预设的 AuthenticationState
private Task<AuthenticationState>? _preRenderedAuthState;
readonly ILocalStorageService _localStorage;
public PersistentAuthenticationStateProvider(PersistentComponentState state, ILocalStorageService localStorageService)
public PersistentAuthenticationStateProvider(IServiceProvider serviceProvider, ILocalStorageService 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;
}
var claims = new List<Claim>
if (state.TryTakeFromJson<UserInfo>(nameof(UserInfo), out var userInfo) && userInfo is not null)
{
new(ClaimKeys.Id, userInfo.Id.ToString()),
new(ClaimKeys.Name, userInfo.Name),
new(ClaimKeys.Email, userInfo.Email),
new(ClaimKeys.Mobile, userInfo.MobilePhone),
new(ClaimKeys.Role, userInfo.Role),
};
foreach (var role in userInfo.Permissions)
{
claims.Add(new Claim(ClaimKeys.Permission, role));
}
var claims = new List<Claim>
{
new(ClaimKeys.Id, userInfo.Id.ToString()),
new(ClaimKeys.Name, userInfo.Name),
new(ClaimKeys.Email, userInfo.Email),
new(ClaimKeys.Mobile, userInfo.MobilePhone),
new(ClaimKeys.Role, userInfo.Role),
};
foreach (var role in userInfo.Permissions ?? Array.Empty<string>())
{
claims.Add(new Claim(ClaimKeys.Permission, role));
}
authenticationStateTask = Task.FromResult(
new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: nameof(PersistentAuthenticationStateProvider)))));
var cp = new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: nameof(PersistentAuthenticationStateProvider)));
_preRenderedAuthState = Task.FromResult(new AuthenticationState(cp));
}
}
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
// 如果在 prerender 阶段已从 PersistentComponentState 恢复用户优先返回该状态Server prerender
if (_preRenderedAuthState != null)
return await _preRenderedAuthState;
try
{
var jwtToken = await _localStorage.GetItemAsStringAsync(StorageKeys.AccessToken);
if (string.IsNullOrEmpty(jwtToken))
return await Task.FromResult(new AuthenticationState(anonymous));
else
{
var getUserClaims = DecryptToken(jwtToken);
if (getUserClaims == null)
return await Task.FromResult(new AuthenticationState(anonymous));
else
{
var claimsPrincipal = SetClaimPrincipal(getUserClaims);
return await Task.FromResult(new AuthenticationState(claimsPrincipal));
}
}
return new AuthenticationState(anonymous);
var getUserClaims = DecryptToken(jwtToken);
if (getUserClaims == null || string.IsNullOrEmpty(getUserClaims.Name))
return new AuthenticationState(anonymous);
var claimsPrincipal = SetClaimPrincipal(getUserClaims);
return new AuthenticationState(claimsPrincipal);
}
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.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));
}
return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: nameof(PersistentAuthenticationStateProvider)));
}
}
public void UpdateAuthenticationState(string jwtToken = "")
@@ -113,8 +114,6 @@ namespace Atomx.Admin.Client.Utils
var handler = new JwtSecurityTokenHandler();
var token = handler.ReadJwtToken(jwtToken);
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 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);
var authState = Task.FromResult(new AuthenticationState(anonymous));
NotifyAuthenticationStateChanged(authState);
}
}