diff --git a/Atomx.Admin/Atomx.Admin.Client/Models/LoginModel.cs b/Atomx.Admin/Atomx.Admin.Client/Models/LoginModel.cs index 1f51a6d..3fcaab4 100644 --- a/Atomx.Admin/Atomx.Admin.Client/Models/LoginModel.cs +++ b/Atomx.Admin/Atomx.Admin.Client/Models/LoginModel.cs @@ -20,6 +20,6 @@ /// /// 是否记住我 /// - public bool SaveMe { get; set; } + public bool RememberMe { get; set; } } } diff --git a/Atomx.Admin/Atomx.Admin.Client/Pages/Login.razor b/Atomx.Admin/Atomx.Admin.Client/Pages/Login.razor index b1d1611..0bcb0aa 100644 --- a/Atomx.Admin/Atomx.Admin.Client/Pages/Login.razor +++ b/Atomx.Admin/Atomx.Admin.Client/Pages/Login.razor @@ -2,7 +2,6 @@ @layout EmptyLayout @inject ILogger Logger - 登录 @if (!dataLoaded) @@ -67,7 +66,6 @@ else private bool _isLoading = false; - protected override void OnInitialized() { if (OperatingSystem.IsBrowser()) @@ -85,12 +83,9 @@ else if (firstRender) { var authState = await AuthStateProvider.GetAuthenticationStateAsync(); - if (authState.User.Identity != null) + if (authState.User.Identity != null && authState.User.Identity.IsAuthenticated) { - if (authState.User.Identity.IsAuthenticated) - { - Navigation.NavigateTo(ReturnUrl ?? "/"); - } + Navigation.NavigateTo(ReturnUrl ?? "/"); } } if (!dataLoaded) @@ -102,32 +97,49 @@ else private async Task LoginAsync() { - if (form.Validate()) + if (!form.Validate()) return; + + _isLoading = true; + StateHasChanged(); + + try { + // 请求后端登录接口,后端返回 ApiResult var api = "/api/sign/in"; - var result = await HttpService.Post>(api, login); - if (result.Success) + var result = await HttpService.Post>(api, login); + if (result.Success && result.Data != null) { - Console.WriteLine("请求api成功"); - if (!string.IsNullOrEmpty(result.Data)) + var auth = result.Data; + + // 保存 access + refresh 到 localStorage(WASM 场景) + await localStorage.SetItemAsync("accessToken", auth.Token); + await localStorage.SetItemAsync("refreshToken", auth.RefreshToken); + + // 更新客户端 AuthenticationState(调用自定义 Provider 更新方法) + if (AuthStateProvider is PersistentAuthenticationStateProvider provider) { - await localStorage.SetItemAsStringAsync(StorageKeys.AccessToken, result.Data); - await localStorage.SetItemAsStringAsync("refreshToken", result.Data); - var authState = (AuthStateProvider as PersistentAuthenticationStateProvider); - if (authState != null) - { - authState.UpdateAuthenticationState(result.Data); - } - Logger.LogInformation($"登录成功跳转目标,{ReturnUrl}"); - Navigation.NavigateTo(ReturnUrl ?? "/"); + // provider 仅需要 access token 更新来触发 UI 更新 + provider.UpdateAuthenticationState(auth.Token); } + + Logger.LogInformation("登录成功,跳转: {ReturnUrl}", ReturnUrl); + Navigation.NavigateTo(ReturnUrl ?? "/"); } else { ModalService.Error(new ConfirmOptions() { Title = "提示", Content = result.Message }); } } - + catch (Exception ex) + { + Logger.LogError(ex, "登录失败"); + ModalService.Error(new ConfirmOptions() { Title = "错误", Content = "登录异常,请稍后重试" }); + } + finally + { + _isLoading = false; + StateHasChanged(); + } } private async Task OnPasswordKeyDown(KeyboardEventArgs value) diff --git a/Atomx.Admin/Atomx.Admin.Client/Program.cs b/Atomx.Admin/Atomx.Admin.Client/Program.cs index 1215574..a399d07 100644 --- a/Atomx.Admin/Atomx.Admin.Client/Program.cs +++ b/Atomx.Admin/Atomx.Admin.Client/Program.cs @@ -6,26 +6,26 @@ using Microsoft.AspNetCore.Components.WebAssembly.Hosting; var builder = WebAssemblyHostBuilder.CreateDefault(args); -// ע᱾ش洢 +// ע᱾ش洢WASM ʹ localStorage tokens builder.Services.AddBlazoredLocalStorageAsSingleton(); -// Ȩ/ +// Ȩ/ݣWASM ʹ AuthenticationStateProvider builder.Services.AddAuthorizationCore(); builder.Services.AddCascadingAuthenticationState(); builder.Services.AddSingleton(); -// Ȩ & ػ +// Ȩ & ػͻʵ֣ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -// Token providerWASM +// Token providerWASM: localStorage ȡ access token builder.Services.AddScoped(); // עԶ token & ˢµ DelegatingHandler builder.Services.AddScoped(); -// עһ HttpClientӦ API ʹã AuthHeaderHandler ܵ +// HttpClientӦͳһʹ ApiClient AuthHeaderHandler ܵ var apiBase = builder.Configuration["WebApi:ServerUrl"] ?? builder.HostEnvironment.BaseAddress; builder.Services.AddHttpClient("ApiClient", client => { @@ -33,8 +33,14 @@ builder.Services.AddHttpClient("ApiClient", client => }) .AddHttpMessageHandler(); -// Ϊעδֵ HttpClientAuthHeaderHandler ڲ CreateClient() ʹĬϹ -// ҲעĬ HttpClient BaseAddress +// עһ AuthHeaderHandler HttpClientˢ tokenѭã +// client AuthHeaderHandler ʹ "RefreshClient" +builder.Services.AddHttpClient("RefreshClient", client => +{ + client.BaseAddress = new Uri(apiBase); +}); + +// Ĭ HttpClientע HttpClient builder.Services.AddScoped(sp => sp.GetRequiredService().CreateClient("ApiClient")); // WASM DI ע HttpServiceʹע HttpClient ʵ diff --git a/Atomx.Admin/Atomx.Admin.Client/Utils/AuthHeaderHandler.cs b/Atomx.Admin/Atomx.Admin.Client/Utils/AuthHeaderHandler.cs index 21dbd05..3685a11 100644 --- a/Atomx.Admin/Atomx.Admin.Client/Utils/AuthHeaderHandler.cs +++ b/Atomx.Admin/Atomx.Admin.Client/Utils/AuthHeaderHandler.cs @@ -13,6 +13,7 @@ namespace Atomx.Admin.Client.Utils /// - 在每次请求时将 access token 附带 Authorization header /// - 在 401 或响应头包含 "Token-Expired" 时尝试刷新(调用 /api/sign/refresh) /// - 防止并发刷新(SemaphoreSlim) + /// - 注意:刷新请求必须使用一个不包含本 handler 的 HttpClient(避免循环) /// public class AuthHeaderHandler : DelegatingHandler { @@ -45,6 +46,7 @@ namespace Atomx.Admin.Client.Utils { try { + // 从 ITokenProvider 获取当前 access token(WASM: ClientTokenProvider 从 localStorage 读取) var token = await _tokenProvider.GetTokenAsync(); if (!string.IsNullOrEmpty(token)) { @@ -57,6 +59,7 @@ namespace Atomx.Admin.Client.Utils var response = await base.SendAsync(request, cancellationToken); + // 当发现未授权或后端标记 token 过期时,尝试刷新 if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || response.Headers.Contains("Token-Expired")) { @@ -76,6 +79,7 @@ namespace Atomx.Admin.Client.Utils } } + // 刷新失败或无 token,跳转到登录页(强制刷新页面,清除 SPA 状态) _navigationManager.NavigateTo("/account/login", true); } else @@ -93,6 +97,13 @@ namespace Atomx.Admin.Client.Utils } } + /// + /// 尝试刷新 token + /// 关键点: + /// - 使用命名 HttpClient "RefreshClient"(在 WASM Program 中注册,不包含本 handler),避免递归 + /// - 并发刷新时只有一个请求会实际触发刷新,其余等待结果 + /// - 刷新成功后写入 localStorage(accessToken + refreshToken) + /// private async Task TryRefreshTokenAsync(CancellationToken cancellationToken) { await _refreshLock.WaitAsync(cancellationToken); @@ -107,11 +118,13 @@ namespace Atomx.Admin.Client.Utils return false; } - var client = _httpClientFactory.CreateClient(); - var reqModel = new + // 使用不带本 handler 的 HttpClient 发送刷新请求,避免循环 + var client = _httpClientFactory.CreateClient("RefreshClient"); + + var reqModel = new RefreshRequest { - token = currentAccess, - refreshToken = currentRefresh + Token = currentAccess, + RefreshToken = currentRefresh }; var reqJson = JsonSerializer.Serialize(reqModel); using var req = new HttpRequestMessage(HttpMethod.Post, "api/sign/refresh") @@ -125,6 +138,9 @@ namespace Atomx.Admin.Client.Utils if (!resp.IsSuccessStatusCode) { _logger.LogWarning("Refresh request failed with status {Status}", resp.StatusCode); + // 如果刷新失败,移除本地 token(防止无限重试) + await _localStorage.RemoveItemAsync(AccessTokenKey); + await _localStorage.RemoveItemAsync(RefreshTokenKey); return false; } @@ -137,9 +153,12 @@ namespace Atomx.Admin.Client.Utils if (authResp == null || string.IsNullOrEmpty(authResp.Token) || string.IsNullOrEmpty(authResp.RefreshToken)) { _logger.LogWarning("Invalid response from refresh endpoint"); + await _localStorage.RemoveItemAsync(AccessTokenKey); + await _localStorage.RemoveItemAsync(RefreshTokenKey); return false; } + // 保存新的 tokens 到 localStorage await _localStorage.SetItemAsync(AccessTokenKey, authResp.Token, cancellationToken); await _localStorage.SetItemAsync(RefreshTokenKey, authResp.RefreshToken, cancellationToken); @@ -158,10 +177,14 @@ namespace Atomx.Admin.Client.Utils } } + /// + /// 复制原始请求并用新的 token 替换 Authorization header + /// private static async Task CloneHttpRequestMessageAsync(HttpRequestMessage original, string newToken) { var clone = new HttpRequestMessage(original.Method, original.RequestUri); + // 复制内容(如果存在) if (original.Content != null) { var ms = new MemoryStream(); @@ -176,9 +199,11 @@ namespace Atomx.Admin.Client.Utils } } + // 复制 headers foreach (var header in original.Headers) clone.Headers.TryAddWithoutValidation(header.Key, header.Value); + // 覆盖 Authorization clone.Headers.Authorization = new AuthenticationHeaderValue("Bearer", newToken); return clone; diff --git a/Atomx.Admin/Atomx.Admin/Controllers/SignController.cs b/Atomx.Admin/Atomx.Admin/Controllers/SignController.cs index 1c87105..6938e99 100644 --- a/Atomx.Admin/Atomx.Admin/Controllers/SignController.cs +++ b/Atomx.Admin/Atomx.Admin/Controllers/SignController.cs @@ -1,5 +1,4 @@ - -using Atomx.Admin.Client.Models; +using Atomx.Admin.Client.Models; using Atomx.Admin.Client.Validators; using Atomx.Admin.Services; using Atomx.Admin.Utils; @@ -16,27 +15,42 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.IdentityModel.Tokens; -using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; -using System.Text; -using System.Threading.Tasks; namespace Atomx.Admin.Controllers { + /// + /// 登录/刷新/登出控制器(重构) + /// 说明: + /// - 返回标准的 AuthResponse(access + refresh + expiry),便于 WASM 客户端保存到 localStorage + /// - 仍保留 Cookie 登录(SignInAsync)以兼容 Blazor Server 场景 + /// - 使用注入的 ITokenService 负责 token 的生成、刷新与撤销(数据库保存 refresh token 哈希) + /// - 提供 /api/sign/in (POST), /api/sign/refresh (POST), /api/sign/out (POST) + /// [Route("api/[controller]")] [ApiController] public class SignController : ControllerBase { - readonly ILogger _logger; - readonly IIdentityService _identityService; - readonly IIdCreatorService _idCreator; - readonly IMapper _mapper; - readonly DataContext _dbContext; - readonly JwtSetting _jwtSetting; - readonly ICacheService _cacheService; - readonly AuthenticationStateProvider _authenticationStateProvider; + private readonly ILogger _logger; + private readonly IIdentityService _identityService; + private readonly IIdCreatorService _idCreator; + private readonly IMapper _mapper; + private readonly DataContext _dbContext; + private readonly JwtSetting _jwtSetting; + private readonly ICacheService _cacheService; + private readonly AuthenticationStateProvider _authenticationStateProvider; + private readonly ITokenService _tokenService; - public SignController(ILogger logger, IIdentityService identityService, IIdCreatorService idCreator, IMapper mapper, DataContext dbContext, JwtSetting jwtSetting, ICacheService cacheService, AuthenticationStateProvider authenticationStateProvider) + public SignController( + ILogger logger, + IIdentityService identityService, + IIdCreatorService idCreator, + IMapper mapper, + DataContext dbContext, + JwtSetting jwtSetting, + ICacheService cacheService, + AuthenticationStateProvider authenticationStateProvider, + ITokenService tokenService) { _logger = logger; _identityService = identityService; @@ -46,106 +60,201 @@ namespace Atomx.Admin.Controllers _jwtSetting = jwtSetting; _cacheService = cacheService; _authenticationStateProvider = authenticationStateProvider; + _tokenService = tokenService; } /// - /// 用户登录系统 + /// 登录:支持邮箱或用户名登录 + /// - 返回 AuthResponse 给 WASM(access + refresh) + /// - 在 Server 场景同时创建 Cookie(兼容 Blazor Server) /// - /// [HttpPost("in")] [AllowAnonymous] - public async Task Login(LoginModel model) + public async Task Login([FromBody] LoginModel model) { var validator = new LoginModelValidator(); var validation = validator.Validate(model); - if (!validation.IsValid) { - var message = validation.Errors.FirstOrDefault()?.ErrorMessage; - var result = new ApiResult().IsFail(message ?? string.Empty, null); - return new JsonResult(result); + var message = validation.Errors.FirstOrDefault()?.ErrorMessage ?? string.Empty; + return new JsonResult(new ApiResult().IsFail(message, null)); } - var tokenHandler = new JwtSecurityTokenHandler(); - var issuer = _jwtSetting.Issuer; - var audience = _jwtSetting.Audience; - var securityKey = _jwtSetting.SecurityKey; - - var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(securityKey)); - var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); - - Common.Entities.Admin? user = null; + // 查询用户(支持 email / username) + Atomx.Common.Entities.Admin? user = null; if (model.Account.Contains("@")) { - user = _dbContext.Admins.Where(p => p.Email == model.Account).SingleOrDefault(); + user = _dbContext.Admins.SingleOrDefault(p => p.Email == model.Account); } else { - user = _dbContext.Admins.Where(p => p.Username == model.Account).SingleOrDefault(); + user = _dbContext.Admins.SingleOrDefault(p => p.Username == model.Account); } if (user == null) { - var result = new ApiResult().IsFail("用户不存在", null); - return new JsonResult(result); + return new JsonResult(new ApiResult().IsFail("用户不存在", null)); } + + // 简单密码校验(项目 uses MD5 存储示例) if (user.Password != model.Password.ToMd5Password()) { - var result = new ApiResult().IsFail("账号密码不正确", null); - return new JsonResult(result); + return new JsonResult(new ApiResult().IsFail("账号密码不正确", null)); } - var role = _dbContext.Roles.Where(p => p.Id == user.RoleId).SingleOrDefault(); + // 生成 access + refresh(TokenService 会把 refresh 的哈希保存到数据库) + var ip = _identityService.GetClientIp(); + var userAgent = Request.Headers["User-Agent"].FirstOrDefault(); + var authResponse = await _tokenService.GenerateTokenAsync(user, ip, userAgent); - var claims = new List() - { - new Claim(ClaimKeys.Id, user.Id.ToString()), - new Claim(ClaimKeys.Email, user.Email), - new Claim(ClaimKeys.Name, user.Username), - new Claim(ClaimKeys.Role, user.RoleId.ToString()), - new Claim(ClaimKeys.Permission, role?.Permission??string.Empty) - }; + // 更新用户登录统计信息 + user.LastLogin = DateTime.UtcNow; + user.LastIp = ip; + user.LoginCount++; + _dbContext.Admins.Update(user); + await _dbContext.SaveChangesAsync(); + + // 为 Blazor Server 场景创建 Cookie(Claims 中包含必要角色/权限) + var role = _dbContext.Roles.SingleOrDefault(p => p.Id == user.RoleId); + var claims = new List + { + new Claim(ClaimKeys.Id, user.Id.ToString()), + new Claim(ClaimKeys.Email, user.Email ?? string.Empty), + new Claim(ClaimKeys.Name, user.Username ?? string.Empty), + new Claim(ClaimKeys.Role, user.RoleId.ToString()), + new Claim(ClaimKeys.Permission, role?.Permission ?? string.Empty) + }; var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + // SignInAsync 创建 HttpOnly Cookie,便于 Server-side 认证 + await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(claimsIdentity), + new AuthenticationProperties + { + IsPersistent = model.RememberMe, + AllowRefresh = true, + ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes) + }); - var tokenDescriptor = new SecurityTokenDescriptor + // 另外将 tokens 写入 HttpOnly Cookie(增强与传统中间件的兼容性) + try { - Subject = claimsIdentity, + var cookieOptions = new CookieOptions + { + HttpOnly = true, + //Secure = !Request.IsLocal(), // 本地调试时允许 http + SameSite = SameSiteMode.Lax, + Expires = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes), + Path = "/" + }; + Response.Cookies.Append("access_token", authResponse.Token, cookieOptions); - Expires = DateTime.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes), - SigningCredentials = credentials, - Issuer = issuer, - Audience = audience - }; - - var tokenString = tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor)); - - var loginResult = new ApiResult().IsSuccess(tokenString); - - user.LastLogin = DateTime.UtcNow; - user.LastIp = _identityService.GetClientIp(); - user.LoginCount++; - _dbContext.Admins.Update(user); - _dbContext.SaveChanges(); - - - await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity)); - - return new JsonResult(loginResult); + var refreshCookieOptions = new CookieOptions + { + HttpOnly = true, + //Secure = !Request.IsLocal(), + SameSite = SameSiteMode.Lax, + Expires = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.RefreshTokenExpirationMinutes), + Path = "/" + }; + Response.Cookies.Append("refresh_token", authResponse.RefreshToken, refreshCookieOptions); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "设置 token cookie 失败(非致命)"); + } + return new JsonResult(new ApiResult().IsSuccess(authResponse)); } /// - /// 用户退出系统 + /// 刷新:客户端传入(可能已过期的)access token 与 refresh token(明文) + /// - TokenService.RefreshTokenAsync 会验证并一次性撤销旧的 refresh token,生成新的对 /// - /// - [HttpGet("out")] + [HttpPost("refresh")] [AllowAnonymous] - public async Task LogoutAsync() + public async Task Refresh([FromBody] RefreshRequest request) { - await HttpContext.SignOutAsync(); - return new JsonResult(new ApiResult()); + if (request == null || string.IsNullOrWhiteSpace(request.Token) || string.IsNullOrWhiteSpace(request.RefreshToken)) + { + return BadRequest(new ApiResult().IsFail("无效的刷新请求", null)); + } + + try + { + var ip = _identityService.GetClientIp(); + var newTokens = await _tokenService.RefreshTokenAsync(request.Token, request.RefreshToken, ip); + + // 更新 cookie(如存在) + try + { + var cookieOptions = new CookieOptions + { + HttpOnly = true, + //Secure = !Request.IsLocal(), + SameSite = SameSiteMode.Lax, + Expires = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes), + Path = "/" + }; + Response.Cookies.Append("access_token", newTokens.Token, cookieOptions); + + var refreshCookieOptions = new CookieOptions + { + HttpOnly = true, + //Secure = !Request.IsLocal(), + SameSite = SameSiteMode.Lax, + Expires = DateTimeOffset.UtcNow.AddMinutes(_jwtSetting.RefreshTokenExpirationMinutes), + Path = "/" + }; + Response.Cookies.Append("refresh_token", newTokens.RefreshToken, refreshCookieOptions); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "刷新 token 时写 cookie 失败(允许)"); + } + + return new JsonResult(new ApiResult().IsSuccess(newTokens)); + } + catch (SecurityTokenException ex) + { + _logger.LogWarning(ex, "刷新失败:刷新令牌无效或 access token 无效"); + return Unauthorized(new ApiResult().IsFail("刷新令牌无效或已过期", null)); + } + catch (Exception ex) + { + _logger.LogError(ex, "刷新令牌时发生内部错误"); + return StatusCode(500, new ApiResult().IsFail("服务器内部错误", null)); + } + } + + /// + /// 登出:可传入 refresh token 以撤销(WASM 前端应同时清除 localStorage) + /// - 本接口会 SignOut Cookie(Server 场景)并尝试撤销 refresh token + /// + [HttpPost("out")] + [AllowAnonymous] + public async Task LogoutAsync([FromBody] RevokeRequest? revokeRequest = null) + { + if (revokeRequest != null && !string.IsNullOrWhiteSpace(revokeRequest.RefreshToken)) + { + try + { + var ip = _identityService.GetClientIp(); + await _tokenService.RevokeTokenAsync(revokeRequest.RefreshToken, ip); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "撤销 refresh token 失败(允许)"); + } + } + + // 清理 Cookie + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + Response.Cookies.Delete("access_token"); + Response.Cookies.Delete("refresh_token"); + + 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 63df380..27240be 100644 --- a/Atomx.Admin/Atomx.Admin/Extensions/AuthorizationExtension.cs +++ b/Atomx.Admin/Atomx.Admin/Extensions/AuthorizationExtension.cs @@ -9,25 +9,24 @@ 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 使用 + /// public static class AuthorizationExtension { - /// - /// 添加身份验证服务 - /// - /// - /// - /// - /// public static void AddAuthorize(this IServiceCollection services, IConfiguration Configuration, IWebHostEnvironment environment) { var jwtSetting = Configuration.GetSection("Authentication:JwtBearer").Get(); if (jwtSetting == null) { - throw new Exception("缺少配置信息"); + throw new Exception("缺少配置信息 Authentication:JwtBearer"); } services.AddSingleton(jwtSetting); - // 从配置读取 Cookie 设置(可在 appsettings.json 的 Authentication:Cookie 节点配置) + // Cookie 配置读取 var cookieConf = Configuration.GetSection("Authentication:Cookie"); var cookieName = cookieConf.GetValue("Name") ?? ".Atomx.Auth"; var cookiePath = cookieConf.GetValue("Path") ?? "/"; @@ -36,30 +35,26 @@ namespace Atomx.Admin.Extensions var securePolicyStr = cookieConf.GetValue("SecurePolicy"); var expireMinutes = cookieConf.GetValue("ExpireMinutes") ?? 60; - // 解析 SameSite(默认:开发环境 Strict,生产环境 None 用于跨站点场景比如前后端分离) SameSiteMode sameSiteMode; if (string.IsNullOrWhiteSpace(sameSiteStr) || !Enum.TryParse(sameSiteStr, true, out sameSiteMode)) { sameSiteMode = environment.IsDevelopment() ? SameSiteMode.Strict : SameSiteMode.None; } - // 解析 SecurePolicy(默认:开发 SameAsRequest,生产 Always) CookieSecurePolicy securePolicy; if (string.IsNullOrWhiteSpace(securePolicyStr) || !Enum.TryParse(securePolicyStr, true, out securePolicy)) { securePolicy = environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; } - //认证配置:注册 Cookie(用于 SignIn/SignOut)和 JwtBearer(用于 API 授权) services.AddAuthentication(options => { - // 默认用于 API 的认证/挑战方案使用 JwtBearer + // 默认用于 API 的认证方案为 JwtBearer options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => { - // Cookie 配置,确保 SignInAsync 能找到处理器 options.Cookie.Name = cookieName; options.Cookie.Path = cookiePath; if (!string.IsNullOrWhiteSpace(cookieDomain)) @@ -88,25 +83,37 @@ namespace Atomx.Admin.Extensions ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, - ValidAudience = jwtSetting.Audience,//Audience + ValidAudience = jwtSetting.Audience, ValidIssuer = jwtSetting.Issuer, - ClockSkew = TimeSpan.FromMinutes(Convert.ToDouble(jwtSetting.ClockSkew)), //过期时钟偏差 + ClockSkew = TimeSpan.FromMinutes(Convert.ToDouble(jwtSetting.ClockSkew)), IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSetting.SecurityKey)) }; + + // 允许从 query string 中获取 access_token(用于 SignalR / WebSocket) options.Events = new JwtBearerEvents { + 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"))) + { + context.Token = accessToken; + } + return Task.CompletedTask; + }, OnChallenge = 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; }, - OnAuthenticationFailed = context => { - //Token expired - if (context.Exception.GetType() == typeof(SecurityTokenExpiredException)) + if (context.Exception?.GetType() == typeof(SecurityTokenExpiredException)) { context.Response.Headers.Append("Token-Expired", "true"); } @@ -115,13 +122,13 @@ namespace Atomx.Admin.Extensions }; }); + // 注册基于权限的策略 services.AddAuthorization(options => { - // 基于权限的策略 var allPermissions = Permissions.GetAllPermissions(); foreach (var permission in allPermissions) { - options.AddPolicy(permission, policy => { policy.Requirements.Add(new PermissionRequirement(permission)); }); + options.AddPolicy(permission, policy => policy.Requirements.Add(new PermissionRequirement(permission))); } }); } diff --git a/Atomx.Admin/Atomx.Admin/Middlewares/MonitoringMiddleware.cs b/Atomx.Admin/Atomx.Admin/Middlewares/MonitoringMiddleware.cs index ede3f70..62c6487 100644 --- a/Atomx.Admin/Atomx.Admin/Middlewares/MonitoringMiddleware.cs +++ b/Atomx.Admin/Atomx.Admin/Middlewares/MonitoringMiddleware.cs @@ -6,23 +6,30 @@ using System.Text.RegularExpressions; namespace Atomx.Admin.Middlewares { + /// + /// 请求监控中间件 + /// 变更点: + /// - 不再在构造函数注入作用域(scoped)服务 IIdentityService,避免在应用启动时从根 provider 解析 scoped 服务导致异常。 + /// - 在每次请求处理时通过 HttpContext.RequestServices 获取 IIdentityService(请求作用域内解析)。 + /// public class MonitoringMiddleware { private readonly RequestDelegate _next; private readonly ILogger _logger; private readonly MonitoringOptions _options; - private readonly IIdentityService _identityService; - public MonitoringMiddleware(RequestDelegate next, ILogger logger, IOptions options, IIdentityService identityService) + public MonitoringMiddleware(RequestDelegate next, ILogger logger, IOptions options) { _next = next; _logger = logger; _options = options.Value; - _identityService = identityService; } public async Task InvokeAsync(HttpContext context) { + // 在请求作用域内解析 IIdentityService,避免在中间件构造时从根 provider 解析 scoped 服务 + var identityService = context.RequestServices.GetService(); + // 检查是否应该跳过监控 if (ShouldSkipMonitoring(context)) { @@ -32,7 +39,7 @@ namespace Atomx.Admin.Middlewares var logInfo = new { - UserId = _identityService.GetUserId(), + UserId = identityService?.GetUserId(), Path = context.Request.Path, Method = context.Request.Method, StartTime = DateTime.UtcNow, @@ -50,7 +57,6 @@ namespace Atomx.Admin.Middlewares { stopwatch.Stop(); - var logData = new { Path = context.Request.Path, diff --git a/Atomx.Admin/Atomx.Admin/Program.cs b/Atomx.Admin/Atomx.Admin/Program.cs index a450b58..1ae1bbf 100644 --- a/Atomx.Admin/Atomx.Admin/Program.cs +++ b/Atomx.Admin/Atomx.Admin/Program.cs @@ -28,7 +28,7 @@ using System.Text.Unicode; var builder = WebApplication.CreateBuilder(args); -// Configure Serilog +// Serilog ãԭ Log.Logger = new LoggerConfiguration() .ReadFrom.Configuration(builder.Configuration) .Enrich.FromLogContext() @@ -37,7 +37,7 @@ Log.Logger = new LoggerConfiguration() .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") .CreateLogger(); -// Add services to the container. +// ע builder.Services.AddRazorComponents() .AddInteractiveServerComponents() .AddInteractiveWebAssemblyComponents(); @@ -56,101 +56,93 @@ builder.Services.AddBlazoredLocalStorage(); builder.Services.AddCascadingAuthenticationState(); builder.Services.AddHttpContextAccessor(); -// Ȩ޷ +// Ȩ޷ & Ȩ builder.Services.AddScoped(); builder.Services.AddScoped(); -// AuthenticationStateProviderServer ʹÿ֤ʵΪĬע +// AuthenticationStateProviderServer ʹÿ֤ʵ builder.Services.AddScoped(); -// Ҫ Server ʹ PersistentAuthenticationStateProvider ľ幦ܣ԰ע builder.Services.AddScoped(); +// ߷ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -// Server ͳһ ITokenProvider ʵ֣WASM Program.cs ע ClientTokenProvider +// Token ProviderServer ʹ ServerTokenProvider builder.Services.AddScoped(); +builder.Services.AddScoped(); // ע TokenService builder.Services.AddScoped(); -// SignalR֧ͨ query string access_token websocket/auth +// ע Token /ˢ/ access & refresh token +// - TokenService ʵ ITokenService DataContextICacheServiceJwtSetting +// - Server ע룬SignController 񽫴 DI ȡʵ +builder.Services.AddScoped(); + +// SignalR÷ Hub ֧֣ע⣺JWT OnMessageReceived AuthorizationExtension д builder.Services.AddSignalR(); +// HttpClient ݷ builder.Services.AddHttpClientApiService(builder.Configuration["WebApi:ServerUrl"] ?? "http://localhost"); builder.Services.AddDataService(); -builder.Services.AddAuthorize(builder.Configuration, builder.Environment); +builder.Services.AddAuthorize(builder.Configuration, builder.Environment); // úõ֤/Ȩ +// EF Core DbContext 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 ע var redisConnection = builder.Configuration.GetConnectionString("cache"); - builder.Services.AddStackExchangeRedisCache(options => { - #region options.Configuration = redisConnection; options.InstanceName = builder.Configuration["RedisCache:InstanceName"]; - #endregion }); -//// Redisֲʽ -//builder.Services.AddSingleton(sp => -// ConnectionMultiplexer.Connect(redisConnection)); - -// Ӧѹ -// Ϊ BrowserRefresh עű HTML Ӧѹ Content-Encoding: br עʧܣ +// Ӧѹ builder.Services.AddResponseCompression(options => { options.EnableForHttps = true; options.Providers.Add(); options.Providers.Add(); - - // ų text/htmlBrowserRefresh Ҫδѹ HTML עű options.MimeTypes = ResponseCompressionDefaults.MimeTypes .Where(m => !string.Equals(m, "text/html", StringComparison.OrdinalIgnoreCase)) .ToArray(); }); - builder.Services.AddOpenApi(); - - builder.Services.AddAntDesign(); - builder.Services.Configure(builder.Configuration.GetSection("Monitoring")); var app = builder.Build(); app.AddDataMigrate(); -// HTTPܵ +// Forwarded headers app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto }); -// Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseWebAssemblyDebugging(); - app.MapScalarApiReference(); // ӳο· - app.MapOpenApi(); // ӳ OpenAPI ĵ· + app.MapScalarApiReference(); + app.MapOpenApi(); } else { app.UseExceptionHandler("/Error", createScopeForErrors: true); } -// ȫͷ -//app.UseSecurityHeaders(); - -// Ӧѹ app.UseResponseCompression(); app.UseCors(option => { + // ע⣺ʹ Cookie Ҫ AllowCredentials ָԴ˴ΪʾԴӦ option.AllowAnyOrigin(); option.AllowAnyMethod(); option.AllowAnyHeader(); @@ -160,63 +152,30 @@ app.UseStaticFiles(new StaticFileOptions { OnPrepareResponse = ctx => { - // 澲̬ļ - ctx.Context.Response.Headers.Append( - "Cache-Control", $"public, max-age={31536000}"); + ctx.Context.Response.Headers.Append("Cache-Control", $"public, max-age={31536000}"); } }); -// -//app.UseRateLimiter(); - +// м˳֤ -> Ȩ app.UseAuthentication(); app.UseAuthorization(); +// Antiforgery & м app.UseAntiforgery(); app.MapStaticAssets(); - app.UseMiddleware(); - -//// ˵ -//app.MapHealthChecks("/health", new HealthCheckOptions -//{ -// ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse, -// Predicate = _ => true, -// AllowCachingResponses = false -//}); - -//app.MapHealthChecks("/health/ready", new HealthCheckOptions -//{ -// ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse, -// Predicate = check => check.Tags.Contains("ready") -//}); - -//app.MapHealthChecks("/health/live", new HealthCheckOptions -//{ -// ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse, -// Predicate = _ => false -//}); - -// SignalR˵ -//app.MapHub("/hubs/chat"); -//app.MapHub("/hubs/notification"); +// SignalR endpointsĿ Hubڴ˴ӳ䣩 +// Hub ChatHubNotificationHubڴȡעͲӳ +// app.MapHub("/hubs/chat"); +// app.MapHub("/hubs/notification"); app.MapControllers(); + +// Blazor ãServer + WASM render modes app.MapRazorComponents() .AddInteractiveServerRenderMode() .AddInteractiveWebAssemblyRenderMode() .AddAdditionalAssemblies(typeof(Atomx.Admin.Client._Imports).Assembly); - -//// ȷݿⴴǨ -//await using (var scope = app.Services.CreateAsyncScope()) -//{ -// var dbContext = scope.ServiceProvider.GetRequiredService(); -// await dbContext.Database.MigrateAsync(); - -// var seeder = scope.ServiceProvider.GetRequiredService(); -// await seeder.SeedAsync(); -//} - app.Run();