From 5bdb04da1588f05f0f942095196131a0627a1bff Mon Sep 17 00:00:00 2001
From: Seany <17074267@qq.com>
Date: Thu, 4 Dec 2025 05:12:45 +0800
Subject: [PATCH] chore
---
.../Atomx.Admin.Client/Models/LoginModel.cs | 2 +-
.../Atomx.Admin.Client/Pages/Login.razor | 56 ++--
Atomx.Admin/Atomx.Admin.Client/Program.cs | 20 +-
.../Utils/AuthHeaderHandler.cs | 33 ++-
.../Atomx.Admin/Controllers/SignController.cs | 255 +++++++++++++-----
.../Extensions/AuthorizationExtension.cs | 49 ++--
.../Middlewares/MonitoringMiddleware.cs | 16 +-
Atomx.Admin/Atomx.Admin/Program.cs | 105 +++-----
8 files changed, 330 insertions(+), 206 deletions(-)
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();