From bd95848972ec6f5a2bf82c3b24a5b0a7d34a31fc Mon Sep 17 00:00:00 2001 From: yxw <17074267@qq.com> Date: Thu, 4 Dec 2025 19:07:04 +0800 Subject: [PATCH] fix auth --- .../Components/AuthorizePermissionView.razor | 77 +++++++++++++++-- .../Atomx.Admin.Client/Pages/Login.razor | 82 ++++++++++++++---- .../Atomx.Admin.Client/Pages/Logout.razor | 79 ++++++++++++++++-- .../Pages/Systems/AdminList.razor | 2 +- Atomx.Admin/Atomx.Admin.Client/Program.cs | 10 ++- .../Atomx.Admin.Client/RedirectToLogin.razor | 1 + .../Services/HttpService.cs | 62 ++++++++++++-- .../PersistentAuthenticationStateProvider.cs | 80 +++++++++--------- .../Atomx.Admin/Controllers/RoleController.cs | 8 +- .../Atomx.Admin/Controllers/SignController.cs | 37 +++++++-- .../Extensions/AuthorizationExtension.cs | 83 +++++++++++++++---- Atomx.Admin/Atomx.Admin/Program.cs | 44 +++++++--- ...RevalidatingAuthenticationStateProvider.cs | 46 ++++++++-- 13 files changed, 484 insertions(+), 127 deletions(-) diff --git a/Atomx.Admin/Atomx.Admin.Client/Components/AuthorizePermissionView.razor b/Atomx.Admin/Atomx.Admin.Client/Components/AuthorizePermissionView.razor index 8a5b8ce..98e0397 100644 --- a/Atomx.Admin/Atomx.Admin.Client/Components/AuthorizePermissionView.razor +++ b/Atomx.Admin/Atomx.Admin.Client/Components/AuthorizePermissionView.razor @@ -1,4 +1,5 @@ @using Microsoft.AspNetCore.Authorization +@using System.Security.Claims @inject IPermissionService PermissionService @inject IAuthorizationService AuthorizationService @@ -24,6 +25,8 @@ @code { + [CascadingParameter] Task? 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 SafeHasPermissionAsync(string permission) + { + try + { + return await PermissionService.HasPermissionAsync(permission); + } + catch + { + // 出错时默认拒绝 + return false; } } } \ No newline at end of file diff --git a/Atomx.Admin/Atomx.Admin.Client/Pages/Login.razor b/Atomx.Admin/Atomx.Admin.Client/Pages/Login.razor index 0bcb0aa..bc2c8ca 100644 --- a/Atomx.Admin/Atomx.Admin.Client/Pages/Login.razor +++ b/Atomx.Admin/Atomx.Admin.Client/Pages/Login.razor @@ -1,6 +1,8 @@ @page "/account/login" +@using System.Text.Json @layout EmptyLayout @inject ILogger Logger +@inject IJSRuntime JS 登录 @@ -48,7 +50,6 @@ else } - @code { string handler = "Server"; @@ -104,30 +105,53 @@ else try { - // 请求后端登录接口,后端返回 ApiResult var api = "/api/sign/in"; - var result = await HttpService.Post>(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("__atomx_post_json", api, login); - // 保存 access + refresh 到 localStorage(WASM 场景) - 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>(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(); } } -} \ No newline at end of file +} + +@* 页面内 JS 辅助:用于在 Server 模式下从浏览器发起 POST 并携带凭证,使浏览器接收 Set-Cookie *@ + \ No newline at end of file diff --git a/Atomx.Admin/Atomx.Admin.Client/Pages/Logout.razor b/Atomx.Admin/Atomx.Admin.Client/Pages/Logout.razor index fc075be..8f9e206 100644 --- a/Atomx.Admin/Atomx.Admin.Client/Pages/Logout.razor +++ b/Atomx.Admin/Atomx.Admin.Client/Pages/Logout.razor @@ -1,11 +1,76 @@ @page "/logout" @layout EmptyLayout +@inject IJSRuntime JS +@inject ILogger 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); - } -} \ No newline at end of file + 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>("/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("__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 *@ + \ No newline at end of file diff --git a/Atomx.Admin/Atomx.Admin.Client/Pages/Systems/AdminList.razor b/Atomx.Admin/Atomx.Admin.Client/Pages/Systems/AdminList.razor index 2a0ebd7..9323058 100644 --- a/Atomx.Admin/Atomx.Admin.Client/Pages/Systems/AdminList.razor +++ b/Atomx.Admin/Atomx.Admin.Client/Pages/Systems/AdminList.razor @@ -33,7 +33,7 @@ 帐号列表
- + @* diff --git a/Atomx.Admin/Atomx.Admin.Client/Program.cs b/Atomx.Admin/Atomx.Admin.Client/Program.cs index 8234f94..91d1e65 100644 --- a/Atomx.Admin/Atomx.Admin.Client/Program.cs +++ b/Atomx.Admin/Atomx.Admin.Client/Program.cs @@ -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 => // Ĭ HttpClientע HttpClient builder.Services.AddScoped(sp => sp.GetRequiredService().CreateClient("ApiClient")); -// WASM DI ע HttpServiceʹע HttpClient ʵ -builder.Services.AddScoped(sp => new HttpService(sp.GetRequiredService())); +// WASM Program.csͻˣע HttpService ʱܴڵ IHttpContextAccessorServer ṩWASM Ϊ null +builder.Services.AddScoped(sp => +{ + var httpClient = sp.GetRequiredService(); + var httpContextAccessor = sp.GetService(); + return new HttpService(httpClient, httpContextAccessor); +}); builder.Services.AddAntDesign(); diff --git a/Atomx.Admin/Atomx.Admin.Client/RedirectToLogin.razor b/Atomx.Admin/Atomx.Admin.Client/RedirectToLogin.razor index 0ed1dcf..0f5fecf 100644 --- a/Atomx.Admin/Atomx.Admin.Client/RedirectToLogin.razor +++ b/Atomx.Admin/Atomx.Admin.Client/RedirectToLogin.razor @@ -3,6 +3,7 @@ @code { protected override void OnInitialized() { + Console.WriteLine("blazor跳转登录页"); Navigation.NavigateTo($"/account/login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true); } } diff --git a/Atomx.Admin/Atomx.Admin.Client/Services/HttpService.cs b/Atomx.Admin/Atomx.Admin.Client/Services/HttpService.cs index 815ce68..7212b9e 100644 --- a/Atomx.Admin/Atomx.Admin.Client/Services/HttpService.cs +++ b/Atomx.Admin/Atomx.Admin.Client/Services/HttpService.cs @@ -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 Get(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 Post(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"); } } + + /// + /// 如果在 Server 环境并且 IHttpContextAccessor 可用,则把浏览器请求的 Cookie 转发到后端请求中 + /// + 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 + { + // 忽略任何转发异常,保持健壮性 + } + } } } diff --git a/Atomx.Admin/Atomx.Admin.Client/Utils/PersistentAuthenticationStateProvider.cs b/Atomx.Admin/Atomx.Admin.Client/Utils/PersistentAuthenticationStateProvider.cs index cbdde12..22f74b2 100644 --- a/Atomx.Admin/Atomx.Admin.Client/Utils/PersistentAuthenticationStateProvider.cs +++ b/Atomx.Admin/Atomx.Admin.Client/Utils/PersistentAuthenticationStateProvider.cs @@ -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 defaultUnauthenticatedTask = Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()))); - readonly Task authenticationStateTask = defaultUnauthenticatedTask; + // 如果运行在 Server 且在 prerender 时有 Persisted UserInfo,则存储预设的 AuthenticationState + private Task? _preRenderedAuthState; readonly ILocalStorageService _localStorage; - public PersistentAuthenticationStateProvider(PersistentComponentState state, ILocalStorageService localStorageService) + public PersistentAuthenticationStateProvider(IServiceProvider serviceProvider, ILocalStorageService localStorageService) { _localStorage = localStorageService; - if (!state.TryTakeFromJson(nameof(UserInfo), out var userInfo) || userInfo is null) + // 尝试有条件解析 PersistedComponentState(仅在 Server 交互渲染时可用) + var state = serviceProvider.GetService(); + if (state != null) { - return; - } - - var claims = new List + if (state.TryTakeFromJson(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 + { + 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()) + { + 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 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()) { 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); } } diff --git a/Atomx.Admin/Atomx.Admin/Controllers/RoleController.cs b/Atomx.Admin/Atomx.Admin/Controllers/RoleController.cs index 3f044b5..253d4ce 100644 --- a/Atomx.Admin/Atomx.Admin/Controllers/RoleController.cs +++ b/Atomx.Admin/Atomx.Admin/Controllers/RoleController.cs @@ -1,6 +1,4 @@ - -using AntDesign; -using Atomx.Admin.Client.Models; +using Atomx.Admin.Client.Models; using Atomx.Admin.Client.Validators; using Atomx.Admin.Services; using Atomx.Common.Constants; @@ -9,16 +7,16 @@ using Atomx.Common.Models; using Atomx.Data; using Atomx.Data.CacheServices; using Atomx.Data.Services; -using Atomx.Utils.Json; using MapsterMapper; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -using System.Threading.Tasks; namespace Atomx.Admin.Controllers { [Route("api/[controller]")] [ApiController] + [Authorize] public class RoleController : ControllerBase { readonly ILogger _logger; diff --git a/Atomx.Admin/Atomx.Admin/Controllers/SignController.cs b/Atomx.Admin/Atomx.Admin/Controllers/SignController.cs index c614ab5..d710cb9 100644 --- a/Atomx.Admin/Atomx.Admin/Controllers/SignController.cs +++ b/Atomx.Admin/Atomx.Admin/Controllers/SignController.cs @@ -191,13 +191,14 @@ namespace Atomx.Admin.Controllers ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes) }); - // 设置Cookie(用于Server模式) + // 设置Cookie(用于Server模式)—— 明确指定与 Logout 同样的属性,便于删除 var cookieOptions = new CookieOptions { HttpOnly = true, Expires = DateTime.UtcNow.AddDays(7), - SameSite = SameSiteMode.Strict, - Secure = Request.IsHttps + SameSite = SameSiteMode.Lax, + Secure = Request.IsHttps, + Path = "/" }; Response.Cookies.Append("accessToken", accessToken, cookieOptions); @@ -318,8 +319,9 @@ namespace Atomx.Admin.Controllers { HttpOnly = true, Expires = DateTime.UtcNow.AddDays(7), - SameSite = SameSiteMode.Strict, - Secure = Request.IsHttps + SameSite = SameSiteMode.Lax, + Secure = Request.IsHttps, + Path = "/" }; Response.Cookies.Append("accessToken", accessToken, cookieOptions); @@ -374,11 +376,28 @@ namespace Atomx.Admin.Controllers } } - // 清理 Cookie - await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + try + { + // 清理 Cookie(SignOutAsync 清除认证会话) + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); - Response.Cookies.Delete("accessToken"); - Response.Cookies.Delete("refreshToken"); + // 显式覆写并过期曾设置的 token cookies,确保浏览器删除(与 Login 写入时使用的选项一致) + 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().IsSuccess("已退出")); } diff --git a/Atomx.Admin/Atomx.Admin/Extensions/AuthorizationExtension.cs b/Atomx.Admin/Atomx.Admin/Extensions/AuthorizationExtension.cs index 1daf239..d45c244 100644 --- a/Atomx.Admin/Atomx.Admin/Extensions/AuthorizationExtension.cs +++ b/Atomx.Admin/Atomx.Admin/Extensions/AuthorizationExtension.cs @@ -1,19 +1,20 @@ using Atomx.Admin.Utils; using Atomx.Common.Constants; using Atomx.Common.Models; -using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using System.Text; -using System.Text.Json; namespace Atomx.Admin.Extensions { /// /// AuthorizationExtension 重构说明: - /// - 配置 Cookie + JwtBearer 双方案,JwtBearer 的 Events 中添加对 SignalR / WebSocket 的 query string access_token 读取(OnMessageReceived) - /// - 保持 OnChallenge 的重定向行为(对于浏览器访问 API 时友好) - /// - 将 JwtSetting 注入为 Singleton 供 TokenService 使用 + /// - 为混合部署(Blazor Server + WASM)与跨站点场景优化 Challenge 行为: + /// * 对浏览器导航/页面请求执行重定向到登录页(保持友好体验) + /// * 对 API / XHR / Fetch / SignalR 请求返回 401 JSON(避免 HTML 重定向导致前端错误) + /// - 保持 JwtBearer 对 SignalR query string 的读取 + /// - Cookie 的 SecurePolicy 根据环境设置,SameSite 使用 Lax(可在跨站点场景改为 None 并开启 AllowCredentials) /// public static class AuthorizationExtension { @@ -28,10 +29,12 @@ namespace Atomx.Admin.Extensions services.AddAuthentication(options => { - // 默认用于 API 的认证方案为 JwtBearer - options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + // 为混合 Server + API 场景选择:默认用于认证/读取身份的方案为 Cookie(Server 端 Circuit 使用) + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + // 当需要挑战(Challenge)时使用 JwtBearer 的行为(其 OnChallenge 可做重定向处理) options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) + // JwtBearer 保持用于 API token 校验 .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => { options.RequireHttpsMetadata = !environment.IsDevelopment(); @@ -55,7 +58,6 @@ namespace Atomx.Admin.Extensions { OnMessageReceived = context => { - // SignalR 客户端常把 token 放在 query string 参数 access_token var accessToken = context.Request.Query["access_token"].FirstOrDefault(); var path = context.HttpContext.Request.Path; if (!string.IsNullOrEmpty(accessToken) && (path.StartsWithSegments("/hubs") || path.StartsWithSegments("/hub") || path.StartsWithSegments("/api"))) @@ -64,13 +66,39 @@ namespace Atomx.Admin.Extensions } return Task.CompletedTask; }, - OnChallenge = context => + OnChallenge = async context => { - // 浏览器端访问 API 并无 token 时重定向到登录页 - var absoluteUri = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}{context.Request.QueryString}"; - context.HandleResponse(); - context.Response.Redirect($"/account/login?returnUrl={Uri.EscapeDataString(absoluteUri)}"); - return Task.CompletedTask; + // 当 JwtChallenge 触发时,根据请求类型决定行为: + // - 浏览器导航 / Accept:text/html(页面请求) => 重定向到登录页 + // - API / XHR / JSON 请求 => 返回 401 JSON + try + { + 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 => { @@ -81,7 +109,32 @@ namespace Atomx.Admin.Extensions 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 => diff --git a/Atomx.Admin/Atomx.Admin/Program.cs b/Atomx.Admin/Atomx.Admin/Program.cs index 2035f9d..119efa6 100644 --- a/Atomx.Admin/Atomx.Admin/Program.cs +++ b/Atomx.Admin/Atomx.Admin/Program.cs @@ -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."); builder.Services.AddDbContext(options => options.UseNpgsql(connection, p => p.MigrationsHistoryTable("__DbMigrationsHistory"))); -// Redis 棨Ѵڣ -// ... ԭ Redis ע - +// Redis var redisConnection = builder.Configuration.GetConnectionString("cache"); builder.Services.AddStackExchangeRedisCache(options => { @@ -105,6 +103,37 @@ builder.Services.AddResponseCompression(options => .ToArray(); }); +// CORSҪʽԴ֧ CookieAllowCredentials +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 +{ + // ʱ originƼ + builder.Services.AddCors(options => + { + options.AddPolicy("DefaultCors", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + // ע⣺AllowAnyOrigin AllowCredentials ͬʱʹ + }); + }); +} + builder.Services.AddOpenApi(); builder.Services.AddAntDesign(); builder.Services.Configure(builder.Configuration.GetSection("Monitoring")); @@ -132,13 +161,8 @@ else app.UseResponseCompression(); -app.UseCors(option => -{ - // ע⣺ʹ Cookie Ҫ AllowCredentials ָԴ˴ΪʾԴӦ - option.AllowAnyOrigin(); - option.AllowAnyMethod(); - option.AllowAnyHeader(); -}); +// ʹ CORS +app.UseCors("DefaultCors"); app.UseStaticFiles(new StaticFileOptions { diff --git a/Atomx.Admin/Atomx.Admin/Utils/PersistingRevalidatingAuthenticationStateProvider.cs b/Atomx.Admin/Atomx.Admin/Utils/PersistingRevalidatingAuthenticationStateProvider.cs index f9fcb5b..5700e6e 100644 --- a/Atomx.Admin/Atomx.Admin/Utils/PersistingRevalidatingAuthenticationStateProvider.cs +++ b/Atomx.Admin/Atomx.Admin/Utils/PersistingRevalidatingAuthenticationStateProvider.cs @@ -17,7 +17,7 @@ namespace Atomx.Admin.Utils private readonly PersistentComponentState _state; private readonly IdentityOptions _options; - private readonly PersistingComponentStateSubscription _subscription; + private readonly List _subscriptions = new(); private Task? _authenticationStateTask; @@ -33,7 +33,28 @@ namespace Atomx.Admin.Utils _options = options.Value; 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); @@ -41,7 +62,9 @@ namespace Atomx.Admin.Utils protected override async Task ValidateAuthenticationStateAsync( 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(); 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 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 { @@ -97,7 +120,18 @@ namespace Atomx.Admin.Utils protected override void Dispose(bool disposing) { - _subscription.Dispose(); + foreach (var sub in _subscriptions) + { + try + { + sub.Dispose(); + } + catch + { + // 忽略单个订阅释放异常,确保其他订阅能够正确释放 + } + } + AuthenticationStateChanged -= OnAuthenticationStateChanged; base.Dispose(disposing); } @@ -105,4 +139,4 @@ namespace Atomx.Admin.Utils public record UserInfo(long Id = 0, string Name = null!, string Email = null!, string MobilePhone = null!, string Role = null!, string[] Permissions = null!); -} \ No newline at end of file +}