fix authorize

This commit is contained in:
2025-12-05 00:27:43 +08:00
parent bd95848972
commit 00dd4fa958
18 changed files with 300 additions and 168 deletions

View File

@@ -0,0 +1,90 @@
@inherits ComponentBase
<CascadingAuthenticationState>
<AuthorizeView Context="authContext">
<Authorized>
@if (_isAuthorized)
{
@ChildContent
}
else if (!string.IsNullOrEmpty(NotAuthorizedContent))
{
@NotAuthorizedContent
}
</Authorized>
<NotAuthorized>
@if (!string.IsNullOrEmpty(NotAuthenticatedContent))
{
@NotAuthenticatedContent
}
</NotAuthorized>
</AuthorizeView>
</CascadingAuthenticationState>
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public string? NotAuthorizedContent { get; set; }
[Parameter] public string? NotAuthenticatedContent { get; set; }
[Parameter] public string? Permission { get; set; } // 单个权限
[Parameter] public string[]? AnyPermissions { get; set; } // 多个权限
[Parameter] public string[]? Roles { get; set; } // 多个角色
[Parameter] public string? Policy { get; set; } // 策略名称
private bool _isAuthorized = false;
protected override async Task OnInitializedAsync()
{
// 如果 Claims 中没有权限信息,使用 PermissionService 异步检查
if (AuthenticationStateTask != null)
{
var authState = await AuthenticationStateTask;
var user = authState.User;
if (user.Identity?.IsAuthenticated ?? false)
{
var userPermissions = user.Claims.Where(c => c.Type == ClaimKeys.Permission).Select(c => c.Value).SingleOrDefault()?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
if(userPermissions == null)
{
userPermissions = new List<string>();
}
// 检查单个权限
if (Roles?.Length > 0)
{
var hasRole = Roles.Any(role => user.IsInRole(role));
if (!hasRole)
{
_isAuthorized = true;
return;
}
}
if (!string.IsNullOrEmpty(Permission))
{
var hasAllPermissions = userPermissions.Contains(Permission);
if (hasAllPermissions)
{
_isAuthorized = true;
return;
}
}
if (AnyPermissions?.Length > 0)
{
var hasAnyPermission = AnyPermissions.Any(p => userPermissions.Contains(p));
if (!hasAnyPermission)
{
_isAuthorized = true;
return;
}
}
}
else
{
_isAuthorized = false;
}
}
}
}

View File

@@ -1,121 +0,0 @@
@using Microsoft.AspNetCore.Authorization
@using System.Security.Claims
@inject IPermissionService PermissionService
@inject IAuthorizationService AuthorizationService
<CascadingAuthenticationState>
<AuthorizeView Context="authContext">
<Authorized>
@if (_hasPermission)
{
@ChildContent
}
else if (!string.IsNullOrEmpty(NotAuthorizedContent))
{
@NotAuthorizedContent
}
</Authorized>
<NotAuthorized>
@if (!string.IsNullOrEmpty(NotAuthenticatedContent))
{
@NotAuthenticatedContent
}
</NotAuthorized>
</AuthorizeView>
</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; }
[Parameter] public bool RequireAll { get; set; }
[Parameter] public string? Policy { get; set; }
[Parameter] public string? NotAuthorizedContent { get; set; }
[Parameter] public string? NotAuthenticatedContent { get; set; }
private bool _hasPermission;
protected override async Task OnParametersSetAsync()
{
_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)
{
_hasPermission = false;
return;
}
// 优先基于声明快速判断(适用于 Server 与 WASM
if (!string.IsNullOrEmpty(Permission))
{
if (user.Claims.Any(c => c.Type == ClaimKeys.Permission && c.Value == Permission))
{
_hasPermission = true;
return;
}
// 回退:调用后端权限服务(适用于 Server-side 权限来源于数据库)
_hasPermission = await SafeHasPermissionAsync(Permission);
return;
}
if (Permissions != null && Permissions.Length > 0)
{
var userPermissions = user.Claims.Where(c => c.Type == ClaimKeys.Permission).Select(c => c.Value).ToHashSet();
if (RequireAll)
{
if (Permissions.All(p => userPermissions.Contains(p)))
{
_hasPermission = true;
return;
}
}
else
{
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

@@ -19,6 +19,9 @@
DefaultValue="umi ui"
Options="DefaultOptions" />
</SpaceItem> *@
<SpaceItem>
<Text Type="TextElementType.Warning">@handler</Text>
</SpaceItem>
<SpaceItem>
<AntDesign.Tooltip Title="@("Help")" Placement="@Placement.Bottom">
<Unbound>
@@ -62,6 +65,8 @@
@code {
string handler = "Server";
private ErrorBoundary? _errorBoundary;
private void ResetError(Exception ex)
@@ -105,6 +110,14 @@
protected async override Task OnInitializedAsync()
{
if (OperatingSystem.IsBrowser())
{
handler = "Wasm";
}
else
{
handler = "Server";
}
var url = "/api/menu/tree";
var apiResult = await HttpService.Get<ApiResult<List<MenuDataItem>>>(url);

View File

@@ -119,9 +119,21 @@ else
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;
// WASM 的 localStorage 在 Server Circuit 中无意义,这里不用写 localStorage。
// WASM 的 localStorage 在 Server Circuit 中无意义,兼容auto模式写入 localStorage。
try
{
await localStorage.SetItemAsync(StorageKeys.AccessToken, token);
await localStorage.SetItemAsync(StorageKeys.RefreshToken, refresh);
if (AuthStateProvider is PersistentAuthenticationStateProvider provider)
{
provider.UpdateAuthenticationState(token);
}
}
catch { }
// 浏览器已通过 fetch 收到 Set-Cookie强制重载使 Circuit 使用新 Cookie。
Logger.LogInformation("登录成功server 跳转: {ReturnUrl}", ReturnUrl);
Logger.LogInformation($"登录成功server 跳转: {ReturnUrl}");
Navigation.NavigateTo(ReturnUrl ?? "/", forceLoad: true);
}
else

View File

@@ -24,7 +24,7 @@
}
catch { /* 忽略网络错误,仍继续清理客户端状态 */ }
if (AuthStateProvider is Atomx.Admin.Client.Utils.PersistentAuthenticationStateProvider provider)
if (AuthStateProvider is PersistentAuthenticationStateProvider provider)
{
await provider.MarkUserAsLoggedOut();
}
@@ -41,6 +41,14 @@
var success = jsResult.ValueKind == JsonValueKind.Object && jsResult.TryGetProperty("success", out var sp) && sp.GetBoolean();
Logger.LogInformation("Server logout result: {Success}", success);
try
{
// 清理 localStorage如果有的话
await localStorage.RemoveItemAsync(StorageKeys.AccessToken);
await localStorage.RemoveItemAsync(StorageKeys.RefreshToken);
}
catch { }
// 尽管我们可能已经处理了服务器态,强制重新加载确保 Circuit 更新
Navigation.NavigateTo("/account/login", forceLoad: true);
}

View File

@@ -32,17 +32,9 @@
<Flex Justify="FlexJustify.SpaceBetween">
帐号列表
<div>
<AuthorizePermissionView Permission="@Permissions.User.Create">
<AuthorizeCheck Permission="@Permissions.Admin.Create">\
<Button Class="me-3" OnClick="OnCreateClick" Type="ButtonType.Primary">新增</Button>
</AuthorizePermissionView>
@* <AuthorizeView Policy="@Permissions.Admin.Edit">
<Authorized>
<Button Class="me-3" OnClick="OnCreateClick" Type="ButtonType.Primary">新增</Button>
</Authorized>
<NotAuthorized>
没有权限
</NotAuthorized>
</AuthorizeView> *@
</AuthorizeCheck>
</div>
</Flex>
</TitleTemplate>
@@ -182,7 +174,7 @@
{
loadQueryString();
LoadList();
_ = LoadList();
base.OnParametersSet();
}
@@ -213,28 +205,34 @@
}
private async void LoadList()
private async Task LoadList()
{
loading = true;
var url = "/api/admin/search";
var apiResult = await HttpService.GetPagingList<Admin>(url, search, Page.GetValueOrDefault(1), PageSize.GetValueOrDefault(20));
if (apiResult.Success)
try
{
if (apiResult.Data != null)
var apiResult = await HttpService.GetPagingList<Admin>(url, search, Page.GetValueOrDefault(1), PageSize.GetValueOrDefault(20));
if (apiResult.Success)
{
PagingList = apiResult.Data;
if (apiResult.Data != null)
{
PagingList = apiResult.Data;
}
}
}
loading = false;
StateHasChanged();
finally
{
loading = false;
StateHasChanged();
}
}
private void OnReset()
{
search = new();
LoadList();
_ = LoadList();
}
void OnSearchReset()
@@ -298,7 +296,7 @@
var apiResult = await HttpService.Post<ApiResult<string>>(url, new());
if (apiResult.Success)
{
LoadList();
_ = LoadList();
await ModalService.InfoAsync(new ConfirmOptions() { Title = "操作提示", Content = "删除数据成功" });
}
else

View File

@@ -1,8 +1,11 @@
using Atomx.Common.Models;
using Atomx.Utils.Json;
using System.Net.Http.Json;
using System.Text;
using Microsoft.AspNetCore.Http;
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
namespace Atomx.Admin.Client.Services
{
@@ -10,11 +13,13 @@ namespace Atomx.Admin.Client.Services
{
private readonly HttpClient _httpClient;
private readonly IHttpContextAccessor? _httpContextAccessor;
private readonly ILogger<HttpService> _logger;
public HttpService(HttpClient httpClient, IHttpContextAccessor? httpContextAccessor = null)
public HttpService(HttpClient httpClient, IHttpContextAccessor? httpContextAccessor = null, ILogger<HttpService>? logger = null)
{
_httpClient = httpClient;
_httpContextAccessor = httpContextAccessor;
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<HttpService>.Instance;
}
public async Task<T> Get<T>(string url)
@@ -29,6 +34,8 @@ namespace Atomx.Admin.Client.Services
}
else
{
await LogNonSuccessAsync(url, response);
ThrowForStatus(response.StatusCode, url);
throw new Exception($"Error: {response.StatusCode}");
}
}
@@ -50,6 +57,8 @@ namespace Atomx.Admin.Client.Services
}
else
{
await LogNonSuccessAsync(url, response);
ThrowForStatus(response.StatusCode, url);
throw new Exception($"Error: {response.StatusCode}");
}
}
@@ -78,13 +87,16 @@ namespace Atomx.Admin.Client.Services
}
else
{
// 明确抛 Unauthorized 以便上层按需处理
throw new Exception($"Error: {response.StatusCode}");
await LogNonSuccessAsync(url, response);
// 明确在 401/403 场景抛出授权异常以便上层 UI/组件做特殊处理
ThrowForStatus(response.StatusCode, url);
throw new Exception($"Error: {response.StatusCode}");
}
}
catch (HttpRequestException ex)
{
Console.WriteLine(ex.ToString());
_logger.LogError(ex, "HttpRequestException while calling {Url}", url);
Console.Error.WriteLine($"[{DateTime.UtcNow:o}] HttpRequestException Url:{url} Error:{ex.Message}");
throw new Exception($"api {url} service failure");
}
}
@@ -115,5 +127,64 @@ namespace Atomx.Admin.Client.Services
// 忽略任何转发异常,保持健壮性
}
}
private async Task LogNonSuccessAsync(string url, HttpResponseMessage response)
{
try
{
var status = response.StatusCode;
var reason = response.ReasonPhrase;
string userId = "unknown";
string ip = "unknown";
var ctx = _httpContextAccessor?.HttpContext;
if (ctx != null)
{
userId = ctx.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? ctx.User?.FindFirst("sub")?.Value
?? "unknown";
if (ctx.Request.Headers.TryGetValue("X-Forwarded-For", out var xff) && !string.IsNullOrWhiteSpace(xff))
{
ip = xff.ToString().Split(',')[0].Trim();
}
else
{
ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
}
}
// 结构化日志记录
_logger.LogWarning("UserId:{UserId} Url:{Url} Ip:{Ip} HttpStatus:{StatusCode} Reason:{ReasonPhrase}", userId, url, ip, (int)status, reason);
// 控制台输出一份,便于本地/容器查看
var consoleMsg = new
{
Timestamp = DateTime.UtcNow.ToString("o"),
Level = "Warning",
UserId = userId,
Url = url,
Ip = ip,
Status = (int)status,
Reason = reason
};
Console.WriteLine(JsonSerializer.Serialize(consoleMsg));
}
catch (Exception ex)
{
// 日志失败不能影响主流程
_logger.LogError(ex, "Failed to log non-success response for {Url}", url);
}
}
private void ThrowForStatus(HttpStatusCode statusCode, string url)
{
if (statusCode == HttpStatusCode.Unauthorized || statusCode == HttpStatusCode.Forbidden)
{
// 抛出明确的授权异常,便于上层按需处理(例如提示登陆、重定向或显示权限不足)
throw new UnauthorizedAccessException($"Error: {statusCode} when calling {url}");
}
}
}
}

View File

@@ -29,7 +29,7 @@ namespace Atomx.Admin.Client.Utils
{
var claims = new List<Claim>
{
new(ClaimKeys.Id, userInfo.Id.ToString()),
new(ClaimKeys.UId, userInfo.Id.ToString()),
new(ClaimKeys.Name, userInfo.Name),
new(ClaimKeys.Email, userInfo.Email),
new(ClaimKeys.Mobile, userInfo.MobilePhone),
@@ -79,7 +79,7 @@ namespace Atomx.Admin.Client.Utils
{
var claims = new List<Claim>
{
new(ClaimKeys.Id, customUserClaims.Id.ToString()),
new(ClaimKeys.UId, customUserClaims.Id.ToString()),
new(ClaimKeys.Name, customUserClaims.Name),
new(ClaimKeys.Email, customUserClaims.Email),
new(ClaimKeys.Mobile, customUserClaims.MobilePhone),
@@ -114,7 +114,7 @@ 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 id = token.Claims.SingleOrDefault(x => x.Type == ClaimKeys.UId)?.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 phone = token.Claims.SingleOrDefault(x => x.Type == ClaimKeys.Mobile)?.Value ?? string.Empty;

View File

@@ -1,5 +1,6 @@
@using System.Net.Http
@using System.Net.Http.Json
@using System.Security.Claims
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms